use std::collections::{HashMap, HashSet};
use std::fmt;
use std::str::FromStr;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const IMPORT_ACTOR: &str = "import";
pub const MAX_ACTOR_LEN: usize = 64;
fn import_actor_for(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{IMPORT_ACTOR}:{hash:016x}")
}
pub const MAX_COUNTER: u64 = 1 << 53;
pub const EMPTY_DOCUMENT: &str = r#"{"elems":[]}"#;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct OpId {
pub counter: u64,
pub actor: String,
}
impl std::hash::Hash for OpId {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let mut head = [0u8; 16];
head[..8].copy_from_slice(&self.counter.to_le_bytes());
head[8..].copy_from_slice(&(self.actor.len() as u64).to_le_bytes());
state.write(&head);
state.write(self.actor.as_bytes());
}
}
impl OpId {
#[must_use]
pub fn new(counter: u64, actor: impl Into<String>) -> Self {
Self {
counter,
actor: actor.into(),
}
}
}
impl fmt::Display for OpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.counter, self.actor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CollabEditError {
#[error("actor must not be empty: an id minted with an empty actor cannot be parsed back")]
EmptyActor,
#[error("actor is {got} bytes, over the limit of {limit}")]
ActorTooLong {
got: usize,
limit: usize,
},
#[error(
"the counter space is exhausted: {wanted} more character(s) would pass the ceiling, \
with only {available} left"
)]
CounterExhausted {
wanted: usize,
available: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("malformed collaborative id {0:?}: expected \"<counter>@<actor>\"")]
pub struct OpIdParseError(String);
impl FromStr for OpId {
type Err = OpIdParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (counter, actor) = s
.split_once('@')
.ok_or_else(|| OpIdParseError(s.to_owned()))?;
if actor.is_empty() || actor.len() > MAX_ACTOR_LEN {
return Err(OpIdParseError(s.to_owned()));
}
let counter = counter.parse().map_err(|_| OpIdParseError(s.to_owned()))?;
Ok(Self {
counter,
actor: actor.to_owned(),
})
}
}
impl Serialize for OpId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for OpId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct OpIdVisitor;
impl Visitor<'_> for OpIdVisitor {
type Value = OpId;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a collaborative id of the form \"<counter>@<actor>\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
v.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_str(OpIdVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum CollabOp {
Insert {
id: OpId,
#[serde(default, skip_serializing_if = "Option::is_none")]
after: Option<OpId>,
ch: char,
},
Delete {
target: OpId,
},
}
impl CollabOp {
const fn minted_counter(&self) -> u64 {
match self {
Self::Insert { id, .. } => id.counter,
Self::Delete { .. } => 0,
}
}
fn ids(&self) -> impl Iterator<Item = &OpId> {
let (minted, referenced) = match self {
Self::Insert { id, after, .. } => (id, after.as_ref()),
Self::Delete { target } => (target, None),
};
std::iter::once(minted).chain(referenced)
}
fn well_formed(&self) -> bool {
self.ids().all(|id| {
!id.actor.is_empty() && id.actor.len() <= MAX_ACTOR_LEN && id.counter < MAX_COUNTER
})
}
const fn variant(&self) -> Option<(&Option<OpId>, char)> {
match self {
Self::Insert { after, ch, .. } => Some((after, *ch)),
Self::Delete { .. } => None,
}
}
const fn insert_id(&self) -> Option<&OpId> {
match self {
Self::Insert { id, .. } => Some(id),
Self::Delete { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Elem {
id: OpId,
#[serde(default, skip_serializing_if = "Option::is_none")]
after: Option<OpId>,
ch: char,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
deleted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollabElement {
pub id: OpId,
pub ch: char,
#[serde(default)]
pub deleted: bool,
}
#[derive(Clone, Default)]
#[cfg_attr(feature = "db", derive(diesel::AsExpression, diesel::FromSqlRow))]
#[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Text))]
pub struct CollabText {
elems: Vec<Elem>,
index: HashSet<OpId>,
pending: Vec<CollabOp>,
buffered: HashSet<CollabOp>,
clock: u64,
}
impl CollabText {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn from_text(actor: &str, text: &str) -> Result<Self, CollabEditError> {
let mut doc = Self::new();
doc.insert(actor, 0, text)?;
Ok(doc)
}
#[must_use]
pub fn text(&self) -> String {
self.elems
.iter()
.filter(|e| !e.deleted)
.map(|e| e.ch)
.collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.elems.iter().filter(|e| !e.deleted).count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.elems.iter().all(|e| e.deleted)
}
#[must_use]
pub const fn clock(&self) -> u64 {
self.clock
}
#[must_use]
pub const fn pending_len(&self) -> usize {
self.pending.len()
}
#[must_use]
pub fn elements(&self) -> Vec<CollabElement> {
self.elems
.iter()
.map(|e| CollabElement {
id: e.id.clone(),
ch: e.ch,
deleted: e.deleted,
})
.collect()
}
#[must_use]
pub const fn element_count(&self) -> usize {
self.elems.len()
}
#[must_use]
pub fn novel_count<'a>(&self, ops: impl IntoIterator<Item = &'a CollabOp>) -> usize {
let ops: Vec<&CollabOp> = ops.into_iter().collect();
let mut integrable: HashSet<&OpId> = HashSet::new();
loop {
let mut grew = false;
for op in &ops {
let CollabOp::Insert { id, after, .. } = op else {
continue;
};
if self.index.contains(id) || integrable.contains(id) {
continue;
}
let anchored = after.as_ref().is_none_or(|anchor| {
self.index.contains(anchor) || integrable.contains(anchor)
});
if anchored {
integrable.insert(id);
grew = true;
}
}
if !grew {
break;
}
}
let charged: HashSet<&&CollabOp> = ops
.iter()
.filter(|op| match op {
CollabOp::Insert { id, .. } => !self.index.contains(id),
CollabOp::Delete { target } => {
!self.index.contains(target) && !integrable.contains(target)
}
})
.filter(|op| !self.buffered.contains(**op))
.collect();
let freed = self
.pending
.iter()
.filter(|op| match op {
CollabOp::Delete { target } => integrable.contains(target),
CollabOp::Insert { .. } => false,
})
.count();
charged.len().saturating_sub(freed)
}
#[must_use]
pub fn already_applied(&self, op: &CollabOp) -> bool {
match op {
CollabOp::Insert { id, .. } => self.index.contains(id),
CollabOp::Delete { target } => self
.position_of(target)
.is_some_and(|at| self.elems[at].deleted),
}
}
#[must_use]
pub fn pending_ops(&self) -> &[CollabOp] {
&self.pending
}
#[must_use]
pub fn holds_pending(&self, op: &CollabOp) -> bool {
self.buffered.contains(op)
}
#[must_use]
pub fn knows(&self, id: &OpId) -> bool {
self.index.contains(id)
}
#[must_use]
pub fn id_at(&self, index: usize) -> Option<OpId> {
self.elems
.iter()
.filter(|e| !e.deleted)
.nth(index)
.map(|e| e.id.clone())
}
pub fn insert(
&mut self,
actor: &str,
index: usize,
text: &str,
) -> Result<Vec<CollabOp>, CollabEditError> {
let anchor = self.anchor_for(index);
self.insert_after(actor, anchor.as_ref(), text)
}
pub fn insert_after(
&mut self,
actor: &str,
after: Option<&OpId>,
text: &str,
) -> Result<Vec<CollabOp>, CollabEditError> {
let wanted = text.chars().count();
Self::preflight_insert(actor, self.clock, wanted)?;
Ok(self.mint(actor, after, text))
}
fn mint(&mut self, actor: &str, after: Option<&OpId>, text: &str) -> Vec<CollabOp> {
let wanted = text.chars().count();
let mut ops = Vec::with_capacity(wanted);
let mut left = after.cloned();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
let next = self.clock.saturating_add(1);
if next >= MAX_COUNTER {
break;
}
self.clock = next;
let id = OpId::new(self.clock, actor);
let anchor = chars.peek().is_some().then(|| id.clone());
let op = CollabOp::Insert {
id,
after: left,
ch,
};
self.apply_ref(&op);
ops.push(op);
left = anchor;
}
ops
}
fn seeded(actor: &str, text: &str) -> Self {
let mut doc = Self::new();
doc.mint(actor, None, text);
doc
}
pub(crate) const fn preflight_insert(
actor: &str,
clock: u64,
wanted: usize,
) -> Result<(), CollabEditError> {
if actor.is_empty() {
return Err(CollabEditError::EmptyActor);
}
if actor.len() > MAX_ACTOR_LEN {
return Err(CollabEditError::ActorTooLong {
got: actor.len(),
limit: MAX_ACTOR_LEN,
});
}
let available = MAX_COUNTER.saturating_sub(clock).saturating_sub(1);
if wanted as u64 > available {
return Err(CollabEditError::CounterExhausted { wanted, available });
}
Ok(())
}
pub fn remove(&mut self, index: usize, count: usize) -> Vec<CollabOp> {
let targets: Vec<OpId> = self
.elems
.iter()
.filter(|e| !e.deleted)
.skip(index)
.take(count)
.map(|e| e.id.clone())
.collect();
self.remove_ids(&targets)
}
pub fn remove_ids(&mut self, ids: &[OpId]) -> Vec<CollabOp> {
let mut ops = Vec::with_capacity(ids.len());
for target in ids {
let op = CollabOp::Delete {
target: target.clone(),
};
self.apply_ref(&op);
ops.push(op);
}
ops
}
pub fn remove_known(&mut self, ids: &[OpId]) -> Vec<CollabOp> {
let known: Vec<OpId> = ids
.iter()
.filter(|id| self.index.contains(id))
.cloned()
.collect();
self.remove_ids(&known)
}
pub fn set_text(
&mut self,
actor: &str,
new_text: &str,
) -> Result<Vec<CollabOp>, CollabEditError> {
let old: Vec<char> = self.text().chars().collect();
let new: Vec<char> = new_text.chars().collect();
let mut prefix = 0;
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
prefix += 1;
}
let mut suffix = 0;
while suffix < old.len() - prefix
&& suffix < new.len() - prefix
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
{
suffix += 1;
}
let added: String = new[prefix..new.len() - suffix].iter().collect();
Self::preflight_insert(actor, self.clock, added.chars().count())?;
let mut ops = self.remove(prefix, old.len() - prefix - suffix);
if !added.is_empty() {
ops.extend(self.insert(actor, prefix, &added)?);
}
Ok(ops)
}
pub fn apply(&mut self, op: CollabOp) -> bool {
if let Some(taken) = self.try_integrate(&op) {
return taken;
}
if self.buffered.insert(op.clone()) {
self.pending.push(op);
}
false
}
pub(crate) fn apply_ref(&mut self, op: &CollabOp) -> bool {
if let Some(taken) = self.try_integrate(op) {
return taken;
}
if self.buffered.insert(op.clone()) {
self.pending.push(op.clone());
}
false
}
fn try_integrate(&mut self, op: &CollabOp) -> Option<bool> {
if op.minted_counter() >= MAX_COUNTER {
return Some(false);
}
if !op.well_formed() {
tracing::warn!(
"collab: refused an operation carrying an id no replica can hold \
(empty or overlong actor, or a counter past the ceiling)"
);
return Some(false);
}
let indexed = op.insert_id().map(|id| self.index.contains(id));
if let CollabOp::Insert { id, after, ch } = op
&& let Some((held_after, held_ch)) = self.held_variant(id, indexed == Some(true))
&& (held_after != after || held_ch != *ch)
{
tracing::warn!(
%id,
"collab: an id was reused for a different character; \
keeping the copy this replica already holds"
);
return Some(false);
}
self.clock = self.clock.max(op.minted_counter());
if self.integrate(op, indexed) {
self.drain_pending();
Some(true)
} else {
None
}
}
pub fn apply_all(&mut self, ops: impl IntoIterator<Item = CollabOp>) {
for op in ops {
self.apply(op);
}
}
pub fn merge(&mut self, other: &Self) {
self.apply_all(other.ops());
}
#[must_use]
pub fn ops(&self) -> Vec<CollabOp> {
let mut ops: Vec<CollabOp> = self
.elems
.iter()
.map(|e| CollabOp::Insert {
id: e.id.clone(),
after: e.after.clone(),
ch: e.ch,
})
.collect();
ops.extend(
self.elems
.iter()
.filter(|e| e.deleted)
.map(|e| CollabOp::Delete {
target: e.id.clone(),
}),
);
ops.extend(self.pending.iter().cloned());
ops
}
fn integrate(&mut self, op: &CollabOp, indexed: Option<bool>) -> bool {
match op {
CollabOp::Insert { id, after, ch } => {
if indexed.unwrap_or_else(|| self.index.contains(id)) {
return true;
}
let Some(start) = self.slot_after(after.as_ref()) else {
return false;
};
let at = self.rga_position(start, id);
self.elems.insert(
at,
Elem {
id: id.clone(),
after: after.clone(),
ch: *ch,
deleted: false,
},
);
self.index.insert(id.clone());
}
CollabOp::Delete { target } => {
let Some(pos) = self.position_of(target) else {
return false;
};
self.elems[pos].deleted = true;
}
}
true
}
fn drain_pending(&mut self) {
while !self.pending.is_empty() {
let mut blocked = Vec::new();
let mut progressed = false;
for op in std::mem::take(&mut self.pending) {
if self.integrate(&op, None) {
self.buffered.remove(&op);
progressed = true;
} else {
blocked.push(op);
}
}
self.pending = blocked;
if !progressed {
break;
}
}
}
fn held_variant(&self, id: &OpId, indexed: bool) -> Option<(&Option<OpId>, char)> {
if indexed && let Some(pos) = self.elems.iter().position(|e| &e.id == id) {
let held = &self.elems[pos];
return Some((&held.after, held.ch));
}
self.pending
.iter()
.find(|op| op.insert_id() == Some(id))
.and_then(CollabOp::variant)
}
fn position_of(&self, id: &OpId) -> Option<usize> {
if !self.index.contains(id) {
return None;
}
self.elems.iter().position(|e| &e.id == id)
}
fn slot_after(&self, after: Option<&OpId>) -> Option<usize> {
let Some(id) = after else {
return Some(0);
};
if self.elems.last().is_some_and(|e| &e.id == id) {
return Some(self.elems.len());
}
self.position_of(id).map(|p| p + 1)
}
fn rga_position(&self, start: usize, id: &OpId) -> usize {
let mut at = start;
while at < self.elems.len() && self.elems[at].id > *id {
at += 1;
}
at
}
fn anchor_for(&self, index: usize) -> Option<OpId> {
let slot = self
.elems
.iter()
.enumerate()
.filter(|(_, e)| !e.deleted)
.nth(index)
.map_or(self.elems.len(), |(i, _)| i);
slot.checked_sub(1).map(|i| self.elems[i].id.clone())
}
}
impl PartialEq for CollabText {
fn eq(&self, other: &Self) -> bool {
if self.elems != other.elems || self.pending.len() != other.pending.len() {
return false;
}
let (mut mine, mut theirs) = (self.pending.clone(), other.pending.clone());
mine.sort();
theirs.sort();
mine == theirs
}
}
impl Eq for CollabText {}
impl fmt::Display for CollabText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text())
}
}
impl fmt::Debug for CollabText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CollabText")
.field("text", &self.text())
.field("elements", &self.elems.len())
.field("pending", &self.pending.len())
.finish_non_exhaustive()
}
}
impl From<&str> for CollabText {
fn from(text: &str) -> Self {
Self::seeded(&import_actor_for(text), text)
}
}
#[derive(Serialize, Deserialize)]
struct Wire {
elems: Vec<Elem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pending: Vec<CollabOp>,
}
impl Serialize for CollabText {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut pending = self.pending.clone();
pending.sort(); Wire {
elems: self.elems.clone(),
pending,
}
.serialize(serializer)
}
}
pub const MAX_WIRE_ELEMENTS: usize = 10_000;
pub const MAX_WIRE_PENDING: usize = 1_000;
impl<'de> Deserialize<'de> for CollabText {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let wire = Wire::deserialize(deserializer)?;
if wire.elems.len() > MAX_WIRE_ELEMENTS {
return Err(serde::de::Error::custom(format!(
"collaborative document carries {} elements, over the limit of {MAX_WIRE_ELEMENTS}",
wire.elems.len(),
)));
}
if wire.pending.len() > MAX_WIRE_PENDING {
return Err(serde::de::Error::custom(format!(
"collaborative document carries {} buffered operations, over the limit of {MAX_WIRE_PENDING}",
wire.pending.len(),
)));
}
if let Some(id) = wire
.elems
.iter()
.flat_map(|elem| std::iter::once(&elem.id).chain(elem.after.as_ref()))
.find(|id| id.counter >= MAX_COUNTER)
{
return Err(serde::de::Error::custom(format!(
"collaborative document carries the unusable id {id}: \
counter {} is at or past the ceiling of {MAX_COUNTER}",
id.counter,
)));
}
let mut seen: HashMap<&OpId, (&Option<OpId>, char)> =
HashMap::with_capacity(wire.elems.len());
for elem in &wire.elems {
if let Some((after, ch)) = seen.insert(&elem.id, (&elem.after, elem.ch))
&& (*after != elem.after || ch != elem.ch)
{
return Err(serde::de::Error::custom(format!(
"collaborative document reuses the id {} for two different characters",
elem.id,
)));
}
}
for op in &wire.pending {
let (Some(id), Some((after, ch))) = (op.insert_id(), op.variant()) else {
continue;
};
if let Some((held_after, held_ch)) = seen.insert(id, (after, ch))
&& (held_after != after || held_ch != ch)
{
return Err(serde::de::Error::custom(format!(
"collaborative document reuses the id {id} for two different characters",
)));
}
}
if let Some(id) = wire
.pending
.iter()
.find_map(|op| op.ids().find(|id| id.counter >= MAX_COUNTER))
{
return Err(serde::de::Error::custom(format!(
"collaborative document buffers an unusable operation: counter {} \
is at or past the ceiling of {MAX_COUNTER}",
id.counter,
)));
}
Self::replay_bounded(wire, MAX_WIRE_PENDING).map_err(|held| {
serde::de::Error::custom(format!(
"collaborative document needs {held} buffered operations to replay, \
over the limit of {MAX_WIRE_PENDING}"
))
})
}
}
impl CollabText {
fn from_wire(wire: Wire) -> Self {
Self::replay(wire, None)
}
fn replay_bounded(wire: Wire, max_pending: usize) -> Result<Self, usize> {
let doc = Self::replay(wire, Some(max_pending));
if doc.pending.len() > max_pending {
return Err(doc.pending.len());
}
Ok(doc)
}
fn replay(wire: Wire, max_pending: Option<usize>) -> Self {
let unusable = wire
.elems
.iter()
.filter(|elem| elem.id.counter >= MAX_COUNTER)
.count()
+ wire
.pending
.iter()
.filter(|op| op.minted_counter() >= MAX_COUNTER)
.count();
if unusable > 0 {
tracing::warn!(
unusable,
ceiling = MAX_COUNTER,
"collab: stored document carries ids past the counter ceiling; \
they cannot be replayed and are dropped"
);
}
let mut doc = Self::new();
let over = |doc: &Self| max_pending.is_some_and(|max| doc.pending.len() > max);
for elem in &wire.elems {
if over(&doc) {
return doc;
}
doc.apply(CollabOp::Insert {
id: elem.id.clone(),
after: elem.after.clone(),
ch: elem.ch,
});
}
for elem in wire.elems.iter().filter(|e| e.deleted) {
doc.apply(CollabOp::Delete {
target: elem.id.clone(),
});
}
for op in wire.pending {
if over(&doc) {
return doc;
}
doc.apply(op);
}
doc
}
#[must_use]
pub fn encode_column(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| EMPTY_DOCUMENT.to_owned())
}
#[must_use]
pub fn decode_column(raw: &str) -> Self {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed == "{}" {
return Self::new();
}
serde_json::from_str::<Wire>(raw).map_or_else(
|_| Self::seeded(&import_actor_for(raw), raw),
Self::from_wire,
)
}
}
#[cfg(feature = "db")]
mod db {
use diesel::backend::Backend;
use diesel::deserialize::{self, FromSql};
use diesel::serialize::{self, IsNull, Output, ToSql};
use diesel::sql_types::Text;
use super::CollabText;
impl ToSql<Text, diesel::sqlite::Sqlite> for CollabText {
fn to_sql<'b>(
&'b self,
out: &mut Output<'b, '_, diesel::sqlite::Sqlite>,
) -> serialize::Result {
out.set_value(self.encode_column());
Ok(IsNull::No)
}
}
#[cfg(feature = "postgres")]
impl ToSql<Text, diesel::pg::Pg> for CollabText {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> serialize::Result {
use std::io::Write as _;
out.write_all(self.encode_column().as_bytes())?;
Ok(IsNull::No)
}
}
impl<DB> FromSql<Text, DB> for CollabText
where
DB: Backend,
String: FromSql<Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
Ok(Self::decode_column(&String::from_sql(bytes)?))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opposite_delivery_orders_converge() {
let mut a = CollabText::new();
let a_ops = a.insert("a", 0, "hello").expect("collab edit refused");
let mut b = CollabText::new();
let b_ops = b.insert("b", 0, "world").expect("collab edit refused");
for op in b_ops {
a.apply(op);
}
for op in a_ops.into_iter().rev() {
b.apply(op);
}
assert_eq!(a.text(), b.text(), "replicas converge");
assert_eq!(a.len(), 10, "no character is lost");
}
#[test]
fn out_of_order_ops_are_buffered_not_dropped() {
let mut source = CollabText::new();
let ops = source.insert("a", 0, "abc").expect("collab edit refused");
let mut target = CollabText::new();
for op in ops.into_iter().rev() {
target.apply(op);
}
assert_eq!(target.text(), "abc", "buffered ops integrate on arrival");
}
#[test]
fn minted_inserts_chain_and_an_unknown_anchor_still_buffers() {
let mut doc = CollabText::new();
let ops = doc
.insert_after("ada", None, "abc")
.expect("collab edit refused");
let ids: Vec<&OpId> = ops.iter().filter_map(CollabOp::insert_id).collect();
let afters: Vec<Option<&OpId>> = ops
.iter()
.map(|op| match op {
CollabOp::Insert { after, .. } => after.as_ref(),
CollabOp::Delete { .. } => None,
})
.collect();
assert_eq!(afters, vec![None, Some(ids[0]), Some(ids[1])]);
assert_eq!(doc.text(), "abc");
let ghost = OpId::new(99, "bob");
let waiting = doc
.insert_after("ada", Some(&ghost), "x")
.expect("an unknown anchor buffers");
assert_eq!(doc.pending_ops(), waiting.as_slice());
assert_eq!(doc.text(), "abc", "nothing lands until the anchor does");
}
#[test]
fn apply_ref_gives_the_same_verdicts_and_document_as_apply() {
let mut source = CollabText::new();
let mut ops = source.insert("a", 0, "abc").expect("collab edit refused");
ops.reverse();
ops.push(ops[0].clone());
ops.push(CollabOp::Delete {
target: OpId::new(7, "ghost"),
});
ops.push(CollabOp::Insert {
id: OpId::new(MAX_COUNTER, "a"),
after: None,
ch: 'x',
});
let CollabOp::Insert { id, after, .. } = ops[0].clone() else {
panic!("the reversed batch starts with an insert");
};
ops.push(CollabOp::Insert { id, after, ch: 'z' });
let mut owned = CollabText::new();
let mut borrowed = CollabText::new();
let by_value: Vec<bool> = ops.iter().map(|op| owned.apply(op.clone())).collect();
let by_ref: Vec<bool> = ops.iter().map(|op| borrowed.apply_ref(op)).collect();
assert_eq!(by_value, [false, false, true, true, false, false, false]);
assert_eq!(by_ref, by_value);
assert_eq!(owned.text(), "abc");
assert_eq!(borrowed.text(), owned.text());
assert_eq!(borrowed.pending_ops(), owned.pending_ops());
assert_eq!(borrowed.ops(), owned.ops());
assert_eq!(borrowed.clock(), owned.clock());
}
#[test]
fn duplicate_ops_are_idempotent() {
let mut source = CollabText::new();
let ops = source.insert("a", 0, "hi").expect("collab edit refused");
let mut target = CollabText::new();
for op in ops.clone() {
target.apply(op);
}
for op in ops {
target.apply(op);
}
assert_eq!(target.text(), "hi");
}
#[test]
fn a_batch_pays_once_for_a_character_it_also_deletes() {
let mut doc = CollabText::new();
let id = OpId::new(1, "ada");
let batch = vec![
CollabOp::Insert {
id: id.clone(),
after: None,
ch: 'x',
},
CollabOp::Insert {
id: id.clone(),
after: None,
ch: 'x',
},
CollabOp::Delete { target: id },
];
assert_eq!(doc.novel_count(batch.iter()), 1);
doc.apply_all(batch);
assert_eq!(doc.element_count(), 1);
assert_eq!(doc.pending_len(), 0);
let orphan = [CollabOp::Delete {
target: OpId::new(9, "bob"),
}];
assert_eq!(doc.novel_count(orphan.iter()), 1);
}
#[test]
fn an_insert_that_drains_a_buffered_delete_is_free() {
let mut doc = CollabText::new();
let target = OpId::new(7, "ada");
doc.apply(CollabOp::Delete {
target: target.clone(),
});
assert_eq!(doc.pending_len(), 1, "the delete is waiting for its target");
let batch = [CollabOp::Insert {
id: target,
after: None,
ch: 'x',
}];
assert_eq!(
doc.novel_count(batch.iter()),
0,
"one slot out, one slot in: the pair costs what the delete already holds"
);
doc.apply_all(batch);
assert_eq!(doc.element_count(), 1);
assert_eq!(doc.pending_len(), 0);
}
#[test]
fn an_oversized_wire_document_is_refused() {
let elems: Vec<serde_json::Value> = (1..=MAX_WIRE_ELEMENTS + 1)
.map(|n| serde_json::json!({ "id": format!("{n}@evil"), "ch": "x" }))
.collect();
let json = serde_json::json!({ "elems": elems, "pending": [] }).to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("over the limit");
assert!(
refused.to_string().contains("over the limit"),
"the refusal says why: {refused}"
);
}
#[test]
fn an_oversized_pending_buffer_is_refused() {
let pending: Vec<serde_json::Value> = (1..=MAX_WIRE_PENDING + 1)
.map(|n| {
serde_json::json!({
"op": "insert",
"id": format!("{n}@evil"),
"after": "99999@ghost",
"ch": "x",
})
})
.collect();
let json = serde_json::json!({ "elems": [], "pending": pending }).to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("over the limit");
assert!(
refused.to_string().contains("buffered operations"),
"the refusal names the buffer: {refused}"
);
}
#[test]
fn a_delete_waiting_on_an_unanchored_insert_is_charged() {
let doc = CollabText::new();
let target = OpId::new(2, "ada");
let batch = [
CollabOp::Delete {
target: target.clone(),
},
CollabOp::Insert {
id: target,
after: Some(OpId::new(99, "ghost")),
ch: 'b',
},
];
assert_eq!(
doc.novel_count(batch.iter()),
2,
"neither one lands, so both sit in the buffer"
);
let mut doc = doc;
doc.apply_all(batch);
assert_eq!(doc.pending_len(), 2);
}
#[test]
fn a_delete_is_free_when_its_insert_lands_behind_another_in_the_batch() {
let doc = CollabText::new();
let first = OpId::new(1, "ada");
let second = OpId::new(2, "ada");
let batch = [
CollabOp::Delete {
target: second.clone(),
},
CollabOp::Insert {
id: second,
after: Some(first.clone()),
ch: 'b',
},
CollabOp::Insert {
id: first,
after: None,
ch: 'a',
},
];
assert_eq!(
doc.novel_count(batch.iter()),
2,
"two characters land; the delete tombstones one of them for free"
);
let mut doc = doc;
doc.apply_all(batch);
assert_eq!(doc.element_count(), 2);
assert_eq!(doc.pending_len(), 0);
assert_eq!(doc.text(), "a");
}
#[test]
fn a_document_that_would_overflow_the_replay_buffer_is_refused() {
let elems: Vec<serde_json::Value> = (1..=MAX_WIRE_PENDING + 1)
.map(|n| {
serde_json::json!({
"id": format!("{n}@evil"),
"after": format!("{}@ghost", 900_000 + n),
"ch": "x",
})
})
.collect();
let json = serde_json::json!({ "elems": elems, "pending": [] }).to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("over the limit");
assert!(
refused
.to_string()
.contains("buffered operations to replay"),
"the refusal says what it would have cost: {refused}"
);
}
#[test]
fn a_scrambled_store_within_the_buffer_still_re_canonicalizes() {
let mut doc = CollabText::new();
doc.insert("ada", 0, "hello").expect("collab edit refused");
let encoded = serde_json::to_string(&doc).expect("encode");
let scrambled = {
let mut value: serde_json::Value = serde_json::from_str(&encoded).expect("parse");
value["elems"].as_array_mut().expect("elems").reverse();
serde_json::to_string(&value).expect("re-encode")
};
let repaired: CollabText = serde_json::from_str(&scrambled).expect("decode");
assert_eq!(
repaired.text(),
"hello",
"put back in the order the merge rule says"
);
}
#[test]
fn an_empty_actor_is_refused_before_it_mints_an_unparseable_id() {
assert_eq!(
CollabText::from_text("", "x").expect_err("empty actor"),
CollabEditError::EmptyActor,
);
let mut doc = CollabText::new();
assert_eq!(
doc.insert("", 0, "x").expect_err("empty actor"),
CollabEditError::EmptyActor,
);
assert_eq!(
doc.set_text("", "x").expect_err("empty actor"),
CollabEditError::EmptyActor,
);
assert_eq!(doc.text(), "", "a refused edit changes nothing");
let good = CollabText::from_text("ada", "hi").expect("non-empty actor");
let encoded = serde_json::to_string(&good).expect("encode");
assert_eq!(
serde_json::from_str::<CollabText>(&encoded)
.expect("decode")
.text(),
"hi",
);
}
#[test]
fn an_insert_that_would_cross_the_ceiling_is_refused_whole() {
let mut doc = CollabText::new();
doc.insert("ada", 0, "seed").expect("collab edit refused");
doc.apply(CollabOp::Insert {
id: OpId::new(MAX_COUNTER - 2, "peer"),
after: None,
ch: 'x',
});
let before = doc.text();
let refused = doc.insert("ada", 0, "ab").expect_err("counter exhausted");
assert!(
matches!(
refused,
CollabEditError::CounterExhausted {
wanted: 2,
available: 1
}
),
"the refusal says what was asked and what was left: {refused}"
);
assert_eq!(doc.text(), before, "nothing of the refused insert landed");
assert_eq!(
doc.insert("ada", 0, "a")
.expect("the last counter is usable")
.len(),
1,
);
}
#[test]
fn a_refused_set_text_does_not_tombstone_the_replaced_span() {
let mut doc = CollabText::new();
doc.insert("ada", 0, "hello").expect("collab edit refused");
doc.apply(CollabOp::Insert {
id: OpId::new(MAX_COUNTER - 2, "peer"),
after: None,
ch: 'x',
});
let refused = doc
.set_text("ada", "goodbye world")
.expect_err("counter exhausted");
assert!(matches!(refused, CollabEditError::CounterExhausted { .. }));
assert!(
doc.text().contains("hello"),
"the old text survives a refused replacement: {:?}",
doc.text()
);
}
#[test]
fn conflicting_inserts_do_not_both_wait_in_the_buffer() {
let anchor_a = OpId::new(1, "a");
let anchor_b = OpId::new(1, "b");
let clash = OpId::new(9, "twice");
let x = CollabOp::Insert {
id: clash.clone(),
after: Some(anchor_a.clone()),
ch: 'x',
};
let y = CollabOp::Insert {
id: clash.clone(),
after: Some(anchor_b.clone()),
ch: 'y',
};
let mut doc = CollabText::new();
assert!(!doc.apply(x.clone()), "waits for its anchor");
assert!(
!doc.apply(y.clone()),
"the second variant of the same id is refused, not buffered"
);
assert_eq!(doc.pending_len(), 1, "only one variant is held");
let seed_a = CollabOp::Insert {
id: anchor_a,
after: None,
ch: 'a',
};
let seed_b = CollabOp::Insert {
id: anchor_b,
after: None,
ch: 'b',
};
let mut kept_from_x = CollabText::new();
kept_from_x.apply_all([x.clone(), y.clone(), seed_a.clone(), seed_b.clone()]);
let mut saw_y_first = CollabText::new();
saw_y_first.apply_all([y, x, seed_b, seed_a]);
for doc in [&kept_from_x, &saw_y_first] {
assert_eq!(
doc.ops()
.iter()
.filter(|op| op.insert_id() == Some(&clash))
.count(),
1,
"one variant of the id, never both, in what this replica passes on"
);
}
assert_eq!(kept_from_x.text(), "bax");
assert_eq!(saw_y_first.text(), "bya");
}
#[test]
fn an_operation_carrying_an_empty_actor_is_refused_on_the_apply_path() {
let mut doc = CollabText::new();
assert!(
!doc.apply(CollabOp::Insert {
id: OpId::new(1, ""),
after: None,
ch: 'x',
}),
"an empty-actor id is refused"
);
assert!(!doc.apply(CollabOp::Insert {
id: OpId::new(2, "ada"),
after: Some(OpId::new(1, "")),
ch: 'y',
}));
assert!(!doc.apply(CollabOp::Delete {
target: OpId::new(1, ""),
}));
assert_eq!(doc.pending_len(), 0, "nothing unwritable is buffered");
doc.insert("ada", 0, "ok").expect("collab edit refused");
let encoded = serde_json::to_string(&doc).expect("encode");
assert_eq!(
serde_json::from_str::<CollabText>(&encoded)
.expect("decode")
.text(),
"ok",
"what the document holds can always be read back"
);
}
#[test]
fn a_document_reusing_an_id_across_buffered_operations_is_refused() {
let json = serde_json::json!({
"elems": [],
"pending": [
{ "op": "insert", "id": "9@twice", "after": "1@a", "ch": "x" },
{ "op": "insert", "id": "9@twice", "after": "1@b", "ch": "y" },
],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("reused id");
assert!(
refused.to_string().contains("two different characters"),
"the refusal names the reuse: {refused}"
);
}
#[test]
fn an_overlong_actor_is_refused_at_every_door() {
let long = "a".repeat(MAX_ACTOR_LEN + 1);
assert!(
format!("1@{long}").parse::<OpId>().is_err(),
"the parser refuses it"
);
assert!(
format!("1@{}", "a".repeat(MAX_ACTOR_LEN))
.parse::<OpId>()
.is_ok(),
"the bound is inclusive"
);
let mut doc = CollabText::new();
assert!(matches!(
doc.insert(&long, 0, "x").expect_err("overlong actor"),
CollabEditError::ActorTooLong {
limit: MAX_ACTOR_LEN,
..
}
));
assert!(!doc.apply(CollabOp::Insert {
id: OpId::new(1, long.clone()),
after: None,
ch: 'x',
}));
assert_eq!(doc.pending_len(), 0);
let json = serde_json::json!({
"elems": [{ "id": format!("1@{long}"), "ch": "x" }],
"pending": [],
})
.to_string();
assert!(serde_json::from_str::<CollabText>(&json).is_err());
}
#[test]
fn a_reference_past_the_ceiling_is_refused_rather_than_buffered() {
let unreachable = OpId::new(MAX_COUNTER, "ghost");
let mut doc = CollabText::new();
assert!(!doc.apply(CollabOp::Insert {
id: OpId::new(1, "ada"),
after: Some(unreachable.clone()),
ch: 'x',
}));
assert!(!doc.apply(CollabOp::Delete {
target: unreachable.clone(),
}));
assert_eq!(
doc.pending_len(),
0,
"nothing waits on a cause that cannot arrive"
);
let json = serde_json::json!({
"elems": [],
"pending": [
{ "op": "insert", "id": "1@ada", "after": unreachable.to_string(), "ch": "x" },
],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("unreachable anchor");
assert!(
refused.to_string().contains("ceiling"),
"the refusal names the ceiling: {refused}"
);
}
#[test]
fn an_element_anchored_past_the_ceiling_is_refused_not_dropped() {
let json = serde_json::json!({
"elems": [
{ "id": "1@a", "after": format!("{MAX_COUNTER}@b"), "ch": "x" },
],
"pending": [],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("unusable anchor");
assert!(
refused.to_string().contains("ceiling"),
"refused rather than silently emptied: {refused}"
);
}
#[test]
fn a_document_reusing_an_id_for_two_characters_is_refused() {
let json = serde_json::json!({
"elems": [
{ "id": "1@ada", "ch": "a" },
{ "id": "1@ada", "after": "1@ada", "ch": "z" },
],
"pending": [],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("reused id");
assert!(
refused.to_string().contains("two different characters"),
"the refusal says why: {refused}"
);
let honest = serde_json::json!({
"elems": [
{ "id": "1@ada", "ch": "a" },
{ "id": "1@ada", "ch": "a" },
],
"pending": [],
})
.to_string();
let doc = serde_json::from_str::<CollabText>(&honest).expect("a repeat, not a conflict");
assert_eq!(doc.text(), "a");
}
#[test]
fn a_stored_document_with_an_unusable_counter_reads_without_it() {
let raw = serde_json::json!({
"elems": [
{ "id": "1@ada", "ch": "a" },
{ "id": format!("{MAX_COUNTER}@broken"), "after": "1@ada", "ch": "b" },
],
"pending": [],
})
.to_string();
let doc = CollabText::decode_column(&raw);
assert_eq!(doc.text(), "a", "the usable character survives");
assert_eq!(doc.pending_len(), 0, "and the unusable one is not buffered");
}
#[test]
fn a_document_with_an_unusable_counter_is_refused() {
let json = serde_json::json!({
"elems": [
{ "id": "1@ada", "ch": "a" },
{ "id": format!("{MAX_COUNTER}@evil"), "after": "1@ada", "ch": "b" },
],
"pending": [],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("unusable id");
assert!(
refused.to_string().contains("at or past the ceiling"),
"the refusal says why: {refused}"
);
}
#[test]
fn a_buffered_operation_with_an_unusable_counter_is_refused() {
let json = serde_json::json!({
"elems": [],
"pending": [
{
"op": "insert",
"id": format!("{}@evil", MAX_COUNTER + 5),
"after": "1@ada",
"ch": "x",
}
],
})
.to_string();
let refused = serde_json::from_str::<CollabText>(&json).expect_err("unusable id");
assert!(
refused.to_string().contains("at or past the ceiling"),
"the refusal says why: {refused}"
);
}
#[test]
fn a_document_at_the_ceiling_still_decodes() {
let mut doc = CollabText::new();
doc.insert("ada", 0, &"x".repeat(MAX_WIRE_ELEMENTS))
.expect("collab edit refused");
let json = serde_json::to_string(&doc).expect("encode");
let back: CollabText = serde_json::from_str(&json).expect("at the ceiling, not over it");
assert_eq!(back.len(), MAX_WIRE_ELEMENTS);
}
#[test]
fn a_batch_of_many_variants_of_one_id_lands_once() {
let doc = CollabText::new();
let id = OpId::new(1, "ada");
let batch: Vec<CollabOp> = (0..5)
.map(|n| CollabOp::Insert {
id: id.clone(),
after: Some(OpId::new(100 + n, "ghost")),
ch: 'x',
})
.collect();
assert_eq!(
doc.novel_count(batch.iter()),
5,
"the preflight charges for the whole batch"
);
let mut doc = doc;
doc.apply_all(batch);
assert_eq!(
doc.pending_len(),
1,
"only the first variant of the id is kept"
);
}
#[test]
fn concurrent_delete_and_insert_both_apply() {
let mut a = CollabText::new();
a.insert("a", 0, "abc").expect("collab edit refused");
let mut b = a.clone();
let del = a.remove(1, 1); let ins = b.insert("b", 3, "!").expect("collab edit refused");
for op in ins {
a.apply(op);
}
for op in del {
b.apply(op);
}
assert_eq!(a.text(), "ac!");
assert_eq!(b.text(), "ac!");
}
#[test]
fn ops_reproduce_the_document() {
let mut a = CollabText::new();
a.insert("a", 0, "abcd").expect("collab edit refused");
a.remove(1, 2);
let mut b = CollabText::new();
for op in a.ops() {
b.apply(op);
}
assert_eq!(b.text(), a.text());
assert_eq!(b.text(), "ad");
}
#[test]
fn insert_at_index_preserves_intent() {
let mut doc = CollabText::new();
doc.insert("a", 0, "ac").expect("collab edit refused");
doc.insert("a", 1, "b").expect("collab edit refused");
assert_eq!(doc.text(), "abc");
assert!(!doc.is_empty());
}
#[test]
fn serde_round_trip_is_lossless() {
let mut doc = CollabText::new();
doc.insert("a", 0, "abc").expect("collab edit refused");
doc.remove(1, 1);
let json = serde_json::to_string(&doc).expect("encode");
let back: CollabText = serde_json::from_str(&json).expect("decode");
assert_eq!(back, doc);
assert_eq!(back.text(), "ac");
assert_eq!(back.elements().len(), 3);
}
#[test]
fn a_bare_string_is_refused() {
let err = serde_json::from_str::<CollabText>("\"hello\"").unwrap_err();
assert!(
err.to_string().contains("invalid type"),
"a string must not decode into a document: {err}"
);
}
#[test]
fn an_unrelated_json_object_does_not_decode_as_an_empty_document() {
assert!(serde_json::from_str::<CollabText>(r#"{"foo":"bar"}"#).is_err());
assert!(serde_json::from_str::<CollabText>("{}").is_err());
let empty: CollabText =
serde_json::from_str(EMPTY_DOCUMENT).expect("the empty document decodes");
assert_eq!(empty, CollabText::new());
assert_eq!(CollabText::new().encode_column(), EMPTY_DOCUMENT);
assert_eq!(CollabText::decode_column(EMPTY_DOCUMENT), CollabText::new());
}
#[test]
fn convergent_replicas_encode_identical_bytes() {
let mut a = CollabText::new();
let a_ops = a.insert("a", 0, "left").expect("collab edit refused");
let mut b = CollabText::new();
let b_ops = b.insert("b", 0, "right").expect("collab edit refused");
for op in b_ops {
a.apply(op);
}
for op in a_ops {
b.apply(op);
}
assert_eq!(
serde_json::to_string(&a).expect("encode a"),
serde_json::to_string(&b).expect("encode b"),
);
}
#[test]
fn set_text_edits_only_the_changed_span() {
let mut a = CollabText::from_text("seed", "the quick fox").expect("collab edit refused");
let mut b = a.clone();
let a_ops = a
.set_text("a", "the quick brown fox")
.expect("collab edit refused"); let b_ops = b
.set_text("b", "THE quick fox")
.expect("collab edit refused");
for op in b_ops {
a.apply(op);
}
for op in a_ops {
b.apply(op);
}
assert_eq!(a.text(), b.text(), "replicas converge");
assert_eq!(a.text(), "THE quick brown fox");
}
#[test]
fn id_anchored_insert_preserves_intent_against_a_stale_index() {
let mut server = CollabText::from_text("seed", "world").expect("collab edit refused");
let anchor = server.id_at(0).expect("first character");
server
.insert("other", 0, "hello ")
.expect("collab edit refused");
server
.insert_after("client", Some(&anchor), "-")
.expect("collab edit refused");
assert_eq!(server.text(), "hello w-orld");
}
#[test]
fn a_plain_text_column_decodes_as_seeded_prose() {
let doc = CollabText::decode_column("legacy note");
assert_eq!(doc.text(), "legacy note");
assert_eq!(CollabText::decode_column("").text(), "");
assert_eq!(doc, CollabText::decode_column("legacy note"));
}
#[test]
fn merge_is_order_independent_and_idempotent() {
let base = CollabText::from_text("seed", "base").expect("collab edit refused");
let mut a = base.clone();
a.insert("a", 0, "A").expect("collab edit refused");
let mut b = base.clone();
b.insert("b", 4, "B").expect("collab edit refused");
let mut c = base;
c.remove(0, 1);
let mut left = a.clone();
left.merge(&b);
left.merge(&c);
let mut right = c.clone();
right.merge(&b);
right.merge(&a);
right.merge(&a);
assert_eq!(left, right);
assert_eq!(left.text(), right.text());
}
#[test]
fn a_delete_for_an_unknown_character_waits() {
let mut source = CollabText::new();
let ins = source.insert("a", 0, "x").expect("collab edit refused");
let del = source.remove(0, 1);
let mut target = CollabText::new();
assert!(!target.apply(del[0].clone()), "delete has no target yet");
assert_eq!(target.pending_len(), 1);
target.apply(ins[0].clone());
assert_eq!(target.pending_len(), 0, "the buffer drains");
assert_eq!(target.text(), "");
}
#[test]
fn a_referenced_id_from_the_future_does_not_move_the_clock() {
let mut doc = CollabText::from_text("seed", "hi").expect("collab edit refused");
let before = doc.clock();
let far_ahead = OpId::new(MAX_COUNTER - 1, "x");
doc.apply(CollabOp::Insert {
id: OpId::new(before + 1, "x"),
after: Some(far_ahead.clone()),
ch: 'z',
});
doc.apply(CollabOp::Delete { target: far_ahead });
assert_eq!(
doc.clock(),
before + 1,
"only the operation's own id advances the clock"
);
doc.insert("seed", 2, "!").expect("collab edit refused");
assert_eq!(doc.text(), "hi!");
}
#[test]
fn a_stored_document_is_rebuilt_rather_than_trusted() {
let duplicated = r#"{"elems":[
{"id":"1@a","ch":"A"},
{"id":"1@a","ch":"Z"}
]}"#;
let doc = CollabText::decode_column(duplicated);
assert_eq!(doc.text(), "A", "the repeated id is dropped");
let misordered = r#"{"elems":[
{"id":"1@a","ch":"A"},
{"id":"5@b","after":"1@a","ch":"B"},
{"id":"9@c","after":"1@a","ch":"C"}
]}"#;
let doc = CollabText::decode_column(misordered);
let replica = {
let mut fresh = CollabText::new();
fresh.apply_all(doc.ops());
fresh
};
assert_eq!(
doc.text(),
replica.text(),
"a decoded document agrees with a replica built from its own ops"
);
assert_eq!(doc, replica);
}
#[test]
fn imported_prose_seeds_do_not_collide() {
let mut hello = CollabText::decode_column("hello");
hello.merge(&CollabText::decode_column("goodbye"));
assert_eq!(
hello.len(),
"hello".len() + "goodbye".len(),
"no character was dropped as already-seen: {:?}",
hello.text()
);
assert_eq!(
CollabText::decode_column("hello"),
CollabText::decode_column("hello")
);
}
#[test]
fn every_spelling_of_empty_decodes_to_an_empty_document() {
for raw in ["", " ", "{}", EMPTY_DOCUMENT] {
assert_eq!(
CollabText::decode_column(raw),
CollabText::new(),
"{raw:?} must decode as the empty document"
);
}
}
#[test]
fn remove_known_refuses_a_character_that_does_not_exist_yet() {
let mut doc = CollabText::from_text("seed", "ab").expect("collab edit refused");
let future = OpId::new(doc.clock() + 50, "victim");
assert!(doc.remove_known(std::slice::from_ref(&future)).is_empty());
assert_eq!(doc.pending_len(), 0, "nothing was buffered");
doc.remove_ids(&[future]);
assert_eq!(doc.pending_len(), 1);
}
#[test]
fn op_ids_round_trip_through_their_string_form() {
let id = OpId::new(7, "ada@host");
assert_eq!(id.to_string(), "7@ada@host");
assert_eq!(id.to_string().parse::<OpId>().expect("parse"), id);
assert!("nope".parse::<OpId>().is_err());
assert!("7@".parse::<OpId>().is_err());
}
}