diff --git a/.changeset/movable-list-apply-diff-moves.md b/.changeset/movable-list-apply-diff-moves.md new file mode 100644 index 000000000..62ab98afa --- /dev/null +++ b/.changeset/movable-list-apply-diff-moves.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": patch +--- + +`applyDiff` on a `LoroMovableList` now keeps every child container that the diff moves. Before, a diff that moved two or more children at once, such as `[{ delete: 2 }, { insert: ["🦜:" + b.id, "🦜:" + a.id] }]` to swap two `LoroText` children, could drop one of them or put elements in the wrong order. `UndoManager.undo()` and `redo()` go through the same code, and after some movable-list edits they could panic with an `OutOfBound` error. diff --git a/AGENTS.md b/AGENTS.md index cb00f0dd4..f0a4c3a0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec. - Bounded decoded-value cache in `InnerStore` (second-chance FIFO, eviction safety contract, loro-dev/loro#1092): [context/container-value-cache.md](context/container-value-cache.md). +- Movable-list `apply_diff`/undo: delete + re-insert of a child becomes a move, + cursor invariant, pitfalls: [context/movable-list-apply-diff.md](context/movable-list-apply-diff.md). - Context backlog: [context/CONTEXT-GAPS.md](context/CONTEXT-GAPS.md). ## Commands diff --git a/context/movable-list-apply-diff.md b/context/movable-list-apply-diff.md new file mode 100644 index 000000000..a204abbc4 --- /dev/null +++ b/context/movable-list-apply-diff.md @@ -0,0 +1,63 @@ +# Movable List `apply_diff` and Undo + +Verified against code 2026-09-28. + +`MovableListHandler::apply_delta` +(`crates/loro-internal/src/handler/movable_list_apply_delta.rs`) turns a list +delta into local movable-list ops. `LoroDoc::apply_diff`, `revert_to`, and +`UndoManager` undo/redo all reach it through `LoroDoc::_apply_diff` → +`Handler::apply_diff`. Undo is only a diff: `LoroDoc::undo_internal` computes +the inverse delta by checkout and replays it through this handler. + +## Contract + +- An inserted container value that matches a child in a deleted range of the + same delta is applied as a move, so the child keeps its id and content. The + lookup follows `container_remap` for children recreated by an earlier + undo/apply_diff. +- Any other container value creates a new child and records + `old id -> new id` in `container_remap`. Later diffs in the batch for the old + id go to the new child. +- An insert with `from_move` whose child still exists elsewhere in the list is + skipped. Undo transforms can leave such inserts behind. +- Scalars have no identity. Deleting and re-inserting a scalar is a delete and + a new insert, even when `is_move` is set. + +## Algorithm + +1. Plan: collect delete ranges in original indices and decide which deleted + children are claimed by the inserts. +2. Delete unclaimed elements right to left, so original indices stay valid. +3. Walk the delta with a live `cursor`. Each inserted value goes right after + the previously placed target element. `live[cursor..]` is the unplaced + claimed children of the current delete gap, followed by the unmoved + original elements at or after the delta position. A retain skips both. A + claimed child found before the cursor was left behind by an earlier retain + and moves to `cursor - 1`. + +Claimed children are located with `get_child_index` (O(log n)). Every step +costs O(log n) plus the op itself. + +## Pitfalls + +- `undo_internal` logs and ignores an `Err` from `_apply_diff`, but a panic + escapes, and in WASM a panic traps. Until 2026-09 this handler deferred all + deletes and tracked moved slots with index arithmetic. The arithmetic + underflowed or produced out-of-range `mov` calls behind an `unwrap()`: a + swap of two children lost one of them, and undo/redo could panic with + `OutOfBound`. Keep index bookkeeping checked by the random tests below. +- The public `ListDiffItem` → `DeltaRope` conversion merges adjacent deletes, + so Rust callers never see `delete 1, delete 2`. loro.js walks its public + delta array item by item and has to handle split deletes itself. +- Moves are not minimal. `delete 3, insert [b, c, a]` moves `b` and `c` + instead of only `a`. Deltas from `LoroDoc::diff` only move elements that + really moved, so they produce the minimal number of ops. + +## Tests + +- `crates/loro/tests/movable_list_apply_diff.rs`: targeted cases, a seeded + differential test against `LoroDoc::diff`, random deltas with undo/redo + across every step, and a two-peer no-panic test. +- `crates/loro-wasm/tests/movable_list.test.ts`: the JS `applyDiff` cases. +- `crates/loro/tests/perf_movable_list_apply_diff.rs` (ignored): 100k-element + timings for `apply_diff` and undo. diff --git a/crates/loro-internal/AGENTS.md b/crates/loro-internal/AGENTS.md index 8f019a78d..2782f48b3 100644 --- a/crates/loro-internal/AGENTS.md +++ b/crates/loro-internal/AGENTS.md @@ -26,6 +26,9 @@ over graceful degradation. before changing mergeable containers. - `src/handler.rs`: typed container handlers, local operation creation, and `MapHandler::ensure_mergeable_*`. + `src/handler/movable_list_apply_delta.rs` applies movable-list diffs for + `apply_diff` and undo: + [../../context/movable-list-apply-diff.md](../../context/movable-list-apply-diff.md). - `src/diff_calc/`: diff calculation when moving between versions. - `docs/diff_calc.md`: design notes for diff calculation. - `docs/critical-version-spec.md`: specification and proof skeleton for diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index 4342ec7f7..95f1a3042 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -25,7 +25,7 @@ use loro_common::{ }; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; -use std::{borrow::Cow, cmp::Reverse, collections::BinaryHeap, fmt::Debug, ops::Deref, sync::Arc}; +use std::{borrow::Cow, fmt::Debug, ops::Deref, sync::Arc}; use tracing::{error, instrument}; pub use crate::diff::diff_impl::UpdateOptions; diff --git a/crates/loro-internal/src/handler/movable_list_apply_delta.rs b/crates/loro-internal/src/handler/movable_list_apply_delta.rs index 5e678af8a..0d95912c3 100644 --- a/crates/loro-internal/src/handler/movable_list_apply_delta.rs +++ b/crates/loro-internal/src/handler/movable_list_apply_delta.rs @@ -1,31 +1,49 @@ +//! `MovableListHandler::apply_delta`: turn a list delta into local movable-list +//! ops. +//! +//! A delete of a child container followed by an insert of the same container +//! id is applied as a move, so the child keeps its identity and content. The +//! delta is applied in three passes: +//! +//! 1. Plan: record the delete ranges in original indices and decide which +//! deleted children are re-inserted ("claimed"). +//! 2. Delete every unclaimed element of the delete ranges, right to left, so +//! the remaining original indices stay valid. +//! 3. Walk the delta again with a cursor in live indices. Every inserted value +//! is placed right after the previously placed target element: new values +//! and containers are inserted there, claimed children are moved there. +//! +//! In pass 3, `live[cursor..]` is always the unplaced claimed children of the +//! current delete gap followed by the unmoved original elements at or after +//! the delta position. A retain therefore skips those pending children plus +//! the retained run. A claimed child found before the cursor was left behind by +//! an earlier retain; moving it to `cursor - 1` keeps the cursor in place. + use super::*; +use crate::{event::Index, state::ContainerState}; +use std::ops::Range; -#[derive(Debug)] -struct ReplacementContext<'a> { - index: &'a mut usize, - index_shift: &'a mut usize, - to_delete: &'a mut FxHashMap, - container_remap: &'a mut FxHashMap, - deleted_indices: &'a mut Vec, - next_deleted: &'a mut BinaryHeap>, +/// Pass 1 output of [`MovableListHandler::apply_delta`]. +struct MovePlan { + /// Delete ranges of the delta in original indices, ascending. + deleted: Vec>, + /// Original indices of the deleted children that the delta re-inserts, + /// ascending. + claimed: Vec, + /// One entry per inserted container value, in delta order: the original + /// index and id of the deleted child it claims, if any. + claims: Vec>, } impl MovableListHandler { - /// Applies a delta to the movable list handler. - /// - /// This function processes the given delta, performing the necessary insertions, - /// deletions, and moves to update the list accordingly. It handles container elements, - /// maintains a map for remapping container IDs, and ensures proper indexing throughout - /// the operation. - /// - /// # Arguments - /// - /// * `delta` - A delta representing the changes to apply. - /// * `container_remap` - A map used to remap container IDs during the operation. + /// Applies a list delta to the movable list. /// - /// # Returns - /// - /// * `LoroResult<()>` - Returns `Ok(())` if successful, or an error if something goes wrong. + /// Container values inserted by the delta move the matching child out of a + /// deleted range of the same delta when there is one, following + /// `container_remap` for children recreated by earlier undo/apply_diff + /// calls. Other container values create new children and record the + /// `old id -> new id` mapping in `container_remap`. An insert with + /// `from_move` whose child still exists elsewhere in the list is skipped. #[tracing::instrument(level = "debug", skip_all)] pub fn apply_delta( &self, @@ -59,317 +77,225 @@ impl MovableListHandler { } } - match &self.inner { - MaybeDetached::Detached(_) => { - unimplemented!(); - } - MaybeDetached::Attached(_) => { - // use tracing::debug; - // debug!( - // "Movable list value before apply_delta: {:#?}", - // self.get_deep_value_with_id() - // ); - // debug!("Applying delta: {:#?}", &delta); - - // Preprocess deletions to build a map of containers to delete. - let mut to_delete = self.preprocess_deletions(&delta); - // Process insertions and moves. - let mut index = 0; - let mut index_shift = 0; - let mut deleted_indices = Vec::new(); - let mut next_deleted = BinaryHeap::new(); - // - positive values are retain - // - negative values are deletions - let mut delta_change: Vec = Vec::new(); - - for delta_item in delta.iter() { - match delta_item { - loro_delta::DeltaItem::Retain { len, .. } => { - index += len; - delta_change.push(*len as isize); - } - loro_delta::DeltaItem::Replace { - value, - delete, - attr, - } => { - // Handle deletions in the current replace operation. - let old_index = index; - self.handle_deletions_in_replace( - *delete, - &mut index, - index_shift, - &mut next_deleted, - ); - delta_change.push(-((index - old_index) as isize)); - - // Process the insertions and moves. - let mut context = ReplacementContext { - index: &mut index, - index_shift: &mut index_shift, - to_delete: &mut to_delete, - container_remap, - deleted_indices: &mut deleted_indices, - next_deleted: &mut next_deleted, - }; - - self.process_replacements(value, attr, &mut context) - .unwrap(); - delta_change.push(value.len() as isize); - } - } - } - - // Apply any remaining deletions. - self.apply_remaining_deletions(delta_change, &mut deleted_indices)?; - Ok(()) - } + if let MaybeDetached::Detached(_) = &self.inner { + unimplemented!(); } + + let plan = self.plan_moves(&delta, container_remap)?; + self.delete_unclaimed(&plan)?; + self.place_inserted_values(&delta, container_remap, plan) } - /// Preprocess deletions to build a map of containers to delete. - /// - /// # Arguments - /// - /// * `delta` - The delta containing the deletions. - /// - /// # Returns - /// - /// * `FxHashMap` - A map of containers to their indices that need to be deleted. - fn preprocess_deletions( + fn plan_moves( &self, delta: &loro_delta::DeltaRope< loro_delta::array_vec::ArrayVec, crate::event::ListDeltaMeta, >, - ) -> FxHashMap { - let mut index = 0; - let mut to_delete = FxHashMap::default(); + container_remap: &FxHashMap, + ) -> LoroResult { + let mut deleted = Vec::new(); + let mut deleted_children: FxHashMap = FxHashMap::default(); + self.with_state(|state| { + let list = state.as_movable_list_state().unwrap(); + let mut index = 0; + for delta_item in delta.iter() { + match delta_item { + loro_delta::DeltaItem::Retain { len, .. } => { + index += *len; + } + loro_delta::DeltaItem::Replace { delete, .. } => { + if *delete == 0 { + continue; + } - for delta_item in delta.iter() { - match delta_item { - loro_delta::DeltaItem::Retain { len, .. } => { - index += len; - } - loro_delta::DeltaItem::Replace { delete, .. } => { - if *delete > 0 { for i in index..index + *delete { - if let Some(LoroValue::Container(c)) = self.get(i) { - to_delete.insert(c, i); + if let Some(LoroValue::Container(c)) = list.get(i, IndexType::ForUser) { + deleted_children.insert(c.clone(), i); } } + deleted.push(index..index + *delete); index += *delete; } } } - } - - to_delete - } + Ok(()) + })?; - /// Handles deletions' effect on the index within a replace operation. - /// It will not perform the deletions. - /// - /// # Arguments - /// - /// * `delete_len` - The number of deletions. - /// * `index` - The current index in the list. - /// * `index_shift` - The current index shift due to previous operations. - /// * `next_deleted` - A heap of indices scheduled for deletion. - fn handle_deletions_in_replace( - &self, - delete_len: usize, - index: &mut usize, - index_shift: usize, - next_deleted: &mut BinaryHeap>, - ) { - if delete_len > 0 { - let mut remaining_deletes = delete_len; - while let Some(Reverse(old_index)) = next_deleted.peek() { - if *old_index + index_shift < *index + remaining_deletes { - assert!(*index <= *old_index + index_shift); - assert!(remaining_deletes > 0); - next_deleted.pop(); - remaining_deletes -= 1; - } else { - break; - } - } - - // Increase the index by the number of deletions handled. - *index += remaining_deletes; - } - } - - /// Processes replacements, handling insertions and moves. - /// - /// # Arguments - /// - /// * `values` - The values to insert or move. - /// * `attr` - Additional attributes for the delta item. - /// * `context` - A context struct containing related parameters. - fn process_replacements( - &self, - values: &loro_delta::array_vec::ArrayVec, - attr: &crate::event::ListDeltaMeta, - context: &mut ReplacementContext, - ) -> LoroResult<()> { - for v in values.iter() { - match v { - ValueOrHandler::Value(LoroValue::Container(old_id)) => { - self.apply_insertion(attr, context, old_id.clone())?; - } - ValueOrHandler::Handler(handler) => { - let old_id = handler.id(); - self.apply_insertion(attr, context, old_id)?; - } - ValueOrHandler::Value(value) => { - self.insert(*context.index, value.clone())?; - Self::update_positions_on_insert(context.to_delete, *context.index, 1); - *context.index += 1; - *context.index_shift += 1; + let mut claimed = Vec::new(); + let mut claims = Vec::new(); + if !deleted_children.is_empty() { + for delta_item in delta.iter() { + let loro_delta::DeltaItem::Replace { value, .. } = delta_item else { + continue; + }; + for v in value.iter() { + let Some(mut id) = inserted_container_id(v) else { + continue; + }; + if !deleted_children.contains_key(&id) { + while let Some(new_id) = container_remap.get(&id) { + id = new_id.clone(); + if deleted_children.contains_key(&id) { + break; + } + } + } + let claim = deleted_children.remove(&id).map(|i| (i, id)); + if let Some((i, _)) = &claim { + claimed.push(*i); + } + claims.push(claim); } } + claimed.sort_unstable(); } - Ok(()) + Ok(MovePlan { + deleted, + claimed, + claims, + }) } - fn apply_insertion( - &self, - attr: &crate::event::ListDeltaMeta, - context: &mut ReplacementContext<'_>, - mut old_id: ContainerID, - ) -> Result<(), LoroError> { - if !context.to_delete.contains_key(&old_id) { - while let Some(new_id) = context.container_remap.get(&old_id) { - old_id = new_id.clone(); - if context.to_delete.contains_key(&old_id) { + fn delete_unclaimed(&self, plan: &MovePlan) -> LoroResult<()> { + let mut claimed = plan.claimed.iter().rev().peekable(); + for range in plan.deleted.iter().rev() { + let mut end = range.end; + while let Some(&&i) = claimed.peek() { + if i < range.start { break; } + debug_assert!(i < range.end); + claimed.next(); + if end > i + 1 { + self.delete(i + 1, end - i - 1)?; + } + end = i; } - } - if let Some(old_index) = context.to_delete.remove(&old_id) { - if old_index > *context.index { - ensure_cov::notify_cov( - "loro_internal::handler::movable_list_apply_delta::process_replacements::mov_0", - ); - self.mov(old_index, *context.index)?; - context.next_deleted.push(Reverse(old_index)); - *context.index += 1; - *context.index_shift += 1; - } else { - ensure_cov::notify_cov( - "loro_internal::handler::movable_list_apply_delta::process_replacements::mov_1", - ); - self.mov(old_index, *context.index - 1)?; + if end > range.start { + self.delete(range.start, end - range.start)?; } - context.deleted_indices.push(old_index); - Self::update_positions_on_delete(context.to_delete, old_index); - Self::update_positions_on_insert(context.to_delete, *context.index, 1); - } else if !attr.from_move || !self.contains_container(&old_id) { - // Insert a new container if not moved. - let new_handler = self.insert_container( - *context.index, - Handler::new_unattached(old_id.container_type()), - )?; - let new_id = new_handler.id(); - context.container_remap.insert(old_id, new_id); - Self::update_positions_on_insert(context.to_delete, *context.index, 1); - *context.index += 1; - *context.index_shift += 1; } - Ok(()) - } - fn contains_container(&self, id: &ContainerID) -> bool { - (0..self.len()).any(|index| { - matches!( - self.get(index), - Some(LoroValue::Container(container_id)) if container_id == *id - ) - }) + Ok(()) } - /// Applies any remaining deletions after processing insertions and moves. - /// - /// # Arguments - /// - /// * `delta` - The delta containing the deletions. - /// * `deleted_indices` - A list of indices that have been deleted. - fn apply_remaining_deletions( + fn place_inserted_values( &self, - delta: Vec, - deleted_indices: &mut Vec, + delta: &loro_delta::DeltaRope< + loro_delta::array_vec::ArrayVec, + crate::event::ListDeltaMeta, + >, + container_remap: &mut FxHashMap, + plan: MovePlan, ) -> LoroResult<()> { - // Sort deleted indices from largest to smallest. - deleted_indices.sort_by_key(|&x| std::cmp::Reverse(x)); - - let mut index: usize = 0; + let MovePlan { + claimed, claims, .. + } = plan; + let mut claims = claims.into_iter(); + let mut placed = vec![false; claimed.len()]; + // Live index where the next target element goes + let mut cursor = 0; + // Delta position in original indices + let mut orig = 0; + // Start of the deletions since the last retain, in original indices + let mut gap_start = 0; + // Unplaced claimed children of the current gap; they are at + // `live[cursor..cursor + pending]` + let mut pending = 0; for delta_item in delta.iter() { - match *delta_item { - x if x > 0 => { - index += x as usize; + match delta_item { + loro_delta::DeltaItem::Retain { len, .. } => { + cursor += pending + *len; + pending = 0; + orig += *len; + gap_start = orig; } - neg_delete => { - let delete = neg_delete.unsigned_abs(); - let mut remaining_deletes = delete; - while let Some(&last) = deleted_indices.last() { - if last < index { - deleted_indices.pop(); - continue; - } + loro_delta::DeltaItem::Replace { + value, + delete, + attr, + } => { + if *delete > 0 { + let start = claimed.partition_point(|&i| i < orig); + let end = claimed.partition_point(|&i| i < orig + *delete); + pending += placed[start..end].iter().filter(|&&p| !p).count(); + orig += *delete; + } + + for v in value.iter() { + let id = match v { + ValueOrHandler::Value(LoroValue::Container(id)) => id.clone(), + ValueOrHandler::Handler(h) => h.id(), + ValueOrHandler::Value(v) => { + self.insert(cursor, v.clone())?; + cursor += 1; + continue; + } + }; - if last < index + remaining_deletes { - deleted_indices.pop(); - remaining_deletes -= 1; - } else { - break; + match claims.next().flatten() { + Some((orig_index, child)) => { + let k = claimed.binary_search(&orig_index).unwrap(); + placed[k] = true; + if gap_start <= orig_index && orig_index < orig { + pending -= 1; + } + let from = self.child_index(&child)?; + if from >= cursor { + self.mov(from, cursor)?; + cursor += 1; + } else { + self.mov(from, cursor - 1)?; + } + } + None => { + let mut id = id; + while let Some(new_id) = container_remap.get(&id) { + id = new_id.clone(); + } + if !attr.from_move || !self.contains_child(&id)? { + let new_handler = self.insert_container( + cursor, + Handler::new_unattached(id.container_type()), + )?; + container_remap.insert(id, new_handler.id()); + cursor += 1; + } + } } } - - self.delete(index, remaining_deletes)?; } } } + debug_assert_eq!(pending, 0); + debug_assert!(placed.iter().all(|&p| p)); Ok(()) } - /// Updates positions in the map after an insertion. - /// - /// Increments positions that are greater than or equal to the insertion index. - /// - /// # Arguments - /// - /// * `positions` - The map of positions to update. - /// * `index` - The index where the insertion occurred. - /// * `len` - The length of the insertion. - fn update_positions_on_insert( - positions: &mut FxHashMap, - index: usize, - len: usize, - ) { - for pos in positions.values_mut() { - if *pos >= index { - *pos += len; - } + /// Live index of a child container that must be in the list. + fn child_index(&self, id: &ContainerID) -> LoroResult { + let index = self.with_state(|state| Ok(state.get_child_index(id)))?; + match index { + Some(Index::Seq(i)) => Ok(i), + _ => panic!( + "moved child {id} is missing from movable list {}", + self.id() + ), } } - /// Updates positions in the map after a deletion. - /// - /// Decrements positions that are greater than or equal to the deletion index. - /// - /// # Arguments - /// - /// * `positions` - The map of positions to update. - /// * `index` - The index where the deletion occurred. - fn update_positions_on_delete(positions: &mut FxHashMap, index: usize) { - for pos in positions.values_mut() { - if *pos >= index { - *pos -= 1; - } - } + fn contains_child(&self, id: &ContainerID) -> LoroResult { + self.with_state(|state| Ok(state.contains_child(id))) + } +} + +fn inserted_container_id(v: &ValueOrHandler) -> Option { + match v { + ValueOrHandler::Value(LoroValue::Container(id)) => Some(id.clone()), + ValueOrHandler::Handler(h) => Some(h.id()), + ValueOrHandler::Value(_) => None, } } diff --git a/crates/loro-wasm/tests/movable_list.test.ts b/crates/loro-wasm/tests/movable_list.test.ts index 0ac95a62a..39f390820 100644 --- a/crates/loro-wasm/tests/movable_list.test.ts +++ b/crates/loro-wasm/tests/movable_list.test.ts @@ -8,6 +8,7 @@ import { LoroMovableList, LoroText, TextDiff, + UndoManager, } from "../bundler/index"; import { expectDefined } from "./helpers"; @@ -215,4 +216,63 @@ describe("movable list", () => { text.insert(0, "Hello"); expect(list.toJSON()).toStrictEqual(["Hello"]); }); + + it("applyDiff moves several child containers at once", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const list = doc.getMovableList("l"); + const a = list.insertContainer(0, new LoroText()); + a.insert(0, "A"); + const b = list.insertContainer(1, new LoroText()); + b.insert(0, "B"); + list.push("x"); + doc.commit(); + const undo = new UndoManager(doc, { mergeInterval: 0 }); + doc.applyDiff([ + [ + list.id, + { + type: "list", + diff: [{ delete: 3 }, { insert: [`🦜:${b.id}`, "y", `🦜:${a.id}`] }], + }, + ], + ]); + doc.commit(); + expect(doc.toJSON()).toStrictEqual({ l: ["B", "y", "A"] }); + expect((list.get(0) as LoroText).id).toBe(b.id); + expect((list.get(2) as LoroText).id).toBe(a.id); + expect(undo.undo()).toBe(true); + expect(doc.toJSON()).toStrictEqual({ l: ["A", "B", "x"] }); + expect(undo.redo()).toBe(true); + expect(doc.toJSON()).toStrictEqual({ l: ["B", "y", "A"] }); + }); + + it("applyDiff treats adjacent delete items like one delete", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const list = doc.getMovableList("l"); + list.push("X"); + list.push("Y"); + const t = list.insertContainer(2, new LoroText()); + t.insert(0, "T"); + list.push("tail"); + doc.commit(); + doc.applyDiff([ + [ + list.id, + { + type: "list", + diff: [ + { delete: 1 }, + { delete: 2 }, + { insert: ["new"] }, + { retain: 1 }, + { insert: [`🦜:${t.id}`] }, + ], + }, + ], + ]); + doc.commit(); + expect(doc.toJSON()).toStrictEqual({ l: ["new", "tail", "T"] }); + }); }); diff --git a/crates/loro/tests/movable_list_apply_diff.rs b/crates/loro/tests/movable_list_apply_diff.rs new file mode 100644 index 000000000..19aca7c78 --- /dev/null +++ b/crates/loro/tests/movable_list_apply_diff.rs @@ -0,0 +1,674 @@ +//! `apply_diff` on a movable list turns "delete a child, insert the same child" +//! into a move, and undo/redo replays diffs through the same code path. + +use loro::{ + event::{Diff, DiffBatch, ListDiffItem}, + ContainerID, ContainerTrait, ContainerType, ExportMode, LoroDoc, LoroMap, LoroMovableList, + LoroResult, LoroText, LoroValue, TextDelta, ToJson, UndoManager, ValueOrContainer, ID, +}; +use pretty_assertions::assert_eq; +use rand::{rngs::StdRng, seq::SliceRandom, Rng, SeedableRng}; +use serde_json::{json, Value}; + +fn cid(id: ContainerID) -> ValueOrContainer { + ValueOrContainer::Value(LoroValue::Container(id)) +} + +fn val(v: impl Into) -> ValueOrContainer { + ValueOrContainer::Value(v.into()) +} + +fn ins(values: Vec) -> ListDiffItem { + ListDiffItem::Insert { + insert: values, + is_move: false, + } +} + +fn del(n: usize) -> ListDiffItem { + ListDiffItem::Delete { delete: n } +} + +fn retain(n: usize) -> ListDiffItem { + ListDiffItem::Retain { retain: n } +} + +fn apply_list_diff(doc: &LoroDoc, target: ContainerID, items: Vec) -> LoroResult<()> { + let mut batch = DiffBatch::default(); + batch.push(target, Diff::List(items)).unwrap(); + doc.apply_diff(batch)?; + doc.commit(); + Ok(()) +} + +fn deep_json(doc: &LoroDoc) -> Value { + doc.get_deep_value().to_json_value() +} + +fn text_list(doc: &LoroDoc, contents: &[&str]) -> LoroResult<(LoroMovableList, Vec)> { + let list = doc.get_movable_list("l"); + let mut texts = Vec::new(); + for s in contents { + let t = list.push_container(LoroText::new())?; + t.insert(0, s)?; + texts.push(t); + } + doc.commit(); + Ok((list, texts)) +} + +/// Applies `items`, checks the result, then checks that undo restores the +/// previous value and redo restores `expected` again. +fn apply_then_undo_redo( + doc: &LoroDoc, + target: ContainerID, + items: Vec, + expected: Value, +) -> LoroResult<()> { + let before = deep_json(doc); + let mut undo = UndoManager::new(doc); + undo.set_merge_interval(0); + apply_list_diff(doc, target, items)?; + assert_eq!(deep_json(doc), expected); + assert!(undo.undo()?); + assert_eq!(deep_json(doc), before); + assert!(undo.redo()?); + assert_eq!(deep_json(doc), expected); + Ok(()) +} + +#[test] +fn apply_diff_swaps_two_child_containers() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let (list, t) = text_list(&doc, &["A", "B"])?; + apply_then_undo_redo( + &doc, + list.id(), + vec![del(2), ins(vec![cid(t[1].id()), cid(t[0].id())])], + json!({"l": ["B", "A"]}), + )?; + // The children are moved, not recreated. + assert_eq!(list.get(0).unwrap().as_container().unwrap().id(), t[1].id()); + assert_eq!(list.get(1).unwrap().as_container().unwrap().id(), t[0].id()); + Ok(()) +} + +#[test] +fn apply_diff_rotates_child_containers() -> LoroResult<()> { + for order in [[1, 2, 0], [2, 0, 1], [2, 1, 0], [0, 2, 1]] { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let (list, t) = text_list(&doc, &["A", "B", "C"])?; + let names = ["A", "B", "C"]; + apply_then_undo_redo( + &doc, + list.id(), + vec![del(3), ins(order.iter().map(|&i| cid(t[i].id())).collect())], + json!({"l": order.iter().map(|&i| names[i]).collect::>()}), + )?; + for (pos, &i) in order.iter().enumerate() { + assert_eq!( + list.get(pos).unwrap().as_container().unwrap().id(), + t[i].id() + ); + } + } + Ok(()) +} + +#[test] +fn apply_diff_moves_children_mixed_with_scalars() -> LoroResult<()> { + let setup = || -> LoroResult<(LoroDoc, LoroMovableList, LoroText, LoroText, LoroText)> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = doc.get_movable_list("l"); + let a = list.push_container(LoroText::new())?; + a.insert(0, "A")?; + list.push("x")?; + let b = list.push_container(LoroText::new())?; + b.insert(0, "B")?; + list.push("y")?; + let c = list.push_container(LoroText::new())?; + c.insert(0, "C")?; + doc.commit(); + Ok((doc, list, a, b, c)) + }; + + // [A, x, B, y, C]: move C to the front, drop x, replace y, move A to the end. + let (doc, list, a, b, c) = setup()?; + let _ = b; + apply_then_undo_redo( + &doc, + list.id(), + vec![ + ins(vec![cid(c.id()), val("n1")]), + del(2), + retain(1), + del(1), + ins(vec![val("n2")]), + del(1), + ins(vec![cid(a.id())]), + ], + json!({"l": ["C", "n1", "B", "n2", "A"]}), + )?; + + // Children moved right past retained scalars end up after them. + let (doc, list, a, b, c) = setup()?; + apply_then_undo_redo( + &doc, + list.id(), + vec![ + del(1), + retain(1), + del(1), + retain(1), + del(1), + ins(vec![cid(c.id()), cid(b.id()), cid(a.id())]), + ], + json!({"l": ["x", "y", "C", "B", "A"]}), + )?; + + // Children moved left are inserted before their own delete. + let (doc, list, _a, b, c) = setup()?; + apply_then_undo_redo( + &doc, + list.id(), + vec![ + ins(vec![cid(c.id())]), + retain(1), + ins(vec![cid(b.id())]), + retain(1), + del(1), + retain(1), + del(1), + ], + json!({"l": ["C", "A", "B", "x", "y"]}), + )?; + + // Mixed: one child moves left, one right, one stays, scalars are + // deleted and inserted around them. + let (doc, list, a, b, c) = setup()?; + apply_then_undo_redo( + &doc, + list.id(), + vec![ + del(1), + ins(vec![val("n0"), cid(c.id())]), + retain(2), + del(2), + ins(vec![val("n1"), cid(a.id()), val("n2")]), + ], + json!({"l": ["n0", "C", "x", "B", "n1", "A", "n2"]}), + )?; + assert_eq!(list.len(), 7); + let _ = b; + Ok(()) +} + +#[test] +fn apply_diff_keeps_child_content_edited_in_the_same_batch() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let (list, t) = text_list(&doc, &["A", "B"])?; + let mut batch = DiffBatch::default(); + batch + .push( + list.id(), + Diff::List(vec![del(2), ins(vec![cid(t[1].id()), cid(t[0].id())])]), + ) + .unwrap(); + batch + .push( + t[0].id(), + Diff::Text(vec![ + TextDelta::Retain { + retain: 1, + attributes: None, + }, + TextDelta::Insert { + insert: "!".into(), + attributes: None, + }, + ]), + ) + .unwrap(); + doc.apply_diff(batch)?; + doc.commit(); + assert_eq!(deep_json(&doc), json!({"l": ["B", "A!"]})); + Ok(()) +} + +/// A delta with several adjacent delete items must behave like one merged +/// delete (loro.js review round 3, B2). +#[test] +fn apply_diff_handles_adjacent_delete_items() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = doc.get_movable_list("l"); + list.push("X")?; + list.push("Y")?; + let t = list.insert_container(2, LoroText::new())?; + t.insert(0, "T")?; + list.push("tail")?; + doc.commit(); + apply_then_undo_redo( + &doc, + list.id(), + vec![ + del(1), + del(2), + ins(vec![val("new")]), + retain(1), + ins(vec![cid(t.id())]), + ], + json!({"l": ["new", "tail", "T"]}), + )?; + + // The Rust diff of `set(0); move(2, 3); move(1, 4)` starts with + // `delete 1, delete 2, insert [set0]`. + let src = LoroDoc::new(); + src.set_peer_id(1)?; + let list = src.get_movable_list("l"); + for i in 0..5 { + list.push(i)?; + } + src.commit(); + let v0 = src.state_frontiers(); + list.set(0, "set0")?; + list.mov(2, 3)?; + list.mov(1, 4)?; + src.commit(); + let receiver = src.fork_at(&v0)?; + receiver.apply_diff(src.diff(&v0, &src.state_frontiers())?)?; + assert_eq!(deep_json(&receiver), deep_json(&src)); + Ok(()) +} + +/// Before the fix, undo after applying this diff panicked with an index +/// underflow (`OutOfBound` in release builds) in the movable list handler. +#[test] +fn undo_after_applying_a_diff_of_cancelling_moves() -> LoroResult<()> { + let src = LoroDoc::new(); + src.set_peer_id(1)?; + let list = src.get_movable_list("l"); + list.insert_container(0, LoroText::new())?.insert(0, "t1")?; + list.insert_container(0, LoroText::new())?.insert(0, "t2")?; + list.set(1, "s")?; + src.commit(); + let v0 = src.state_frontiers(); + list.mov(0, 1)?; + list.mov(0, 1)?; + src.commit(); + let v1 = src.state_frontiers(); + + let receiver = src.fork_at(&v0)?; + receiver.set_peer_id(2)?; + let mut undo = UndoManager::new(&receiver); + undo.set_merge_interval(0); + receiver.apply_diff(src.diff(&v0, &v1)?)?; + receiver.commit(); + assert_eq!(deep_json(&receiver), json!({"l": ["t2", "s"]})); + assert!(undo.undo()?); + assert_eq!(deep_json(&receiver), json!({"l": ["t2", "s"]})); + assert!(undo.redo()?); + assert_eq!(deep_json(&receiver), json!({"l": ["t2", "s"]})); + Ok(()) +} + +fn movable_list(doc: &LoroDoc, nested: bool) -> LoroMovableList { + if nested { + doc.get_map("root") + .ensure_mergeable_movable_list("l") + .unwrap() + } else { + doc.get_movable_list("l") + } +} + +fn random_list_op(rng: &mut StdRng, list: &LoroMovableList, n: &mut i64) { + let len = list.len(); + *n += 1; + let n = *n; + match rng.gen_range(0..8) { + 0 => list.insert(rng.gen_range(0..=len), n).unwrap(), + 1 => { + let t = list + .insert_container(rng.gen_range(0..=len), LoroText::new()) + .unwrap(); + t.insert(0, &format!("t{n}")).unwrap(); + } + 2 => { + let m = list + .insert_container(rng.gen_range(0..=len), LoroMap::new()) + .unwrap(); + m.insert("k", n).unwrap(); + } + 3 if len > 0 => { + let pos = rng.gen_range(0..len); + let del_len = rng.gen_range(1..=2.min(len - pos)); + list.delete(pos, del_len).unwrap(); + } + 4 if len > 1 => list + .mov(rng.gen_range(0..len), rng.gen_range(0..len)) + .unwrap(), + 5 if len > 0 => list.set(rng.gen_range(0..len), format!("s{n}")).unwrap(), + 6 if len > 0 => { + let m = list + .set_container(rng.gen_range(0..len), LoroMap::new()) + .unwrap(); + m.insert("set", n).unwrap(); + } + 7 if len > 0 => { + if let Some(ValueOrContainer::Container(loro::Container::Text(t))) = + list.get(rng.gen_range(0..len)) + { + t.insert(0, "+").unwrap(); + } + } + _ => list.push(n).unwrap(), + } +} + +/// Differential test against the Rust diff: for random movable list histories, +/// `apply_diff(diff(a, b))` on a fork at `a` must produce `b`, and undo/redo +/// must switch between `a` and `b`. +#[test] +fn random_history_diff_apply_undo_redo() -> LoroResult<()> { + for seed in 0..64u64 { + for nested in [false, true] { + let mut rng = StdRng::seed_from_u64(seed); + let src = LoroDoc::new(); + src.set_peer_id(1)?; + let list = movable_list(&src, nested); + let mut n = 0; + for _ in 0..4 { + random_list_op(&mut rng, &list, &mut n); + } + src.commit(); + for round in 0..4 { + let a = src.state_frontiers(); + let before = deep_json(&src); + for _ in 0..8 { + random_list_op(&mut rng, &list, &mut n); + } + src.commit(); + let b = src.state_frontiers(); + let after = deep_json(&src); + + let receiver = src.fork_at(&a)?; + receiver.set_peer_id(2)?; + let mut undo = UndoManager::new(&receiver); + undo.set_merge_interval(0); + receiver.apply_diff(src.diff(&a, &b)?)?; + receiver.commit(); + let ctx = format!("seed={seed} nested={nested} round={round}"); + assert_eq!(deep_json(&receiver), after, "apply {ctx}"); + undo.undo()?; + assert_eq!(deep_json(&receiver), before, "undo {ctx}"); + undo.redo()?; + assert_eq!(deep_json(&receiver), after, "redo {ctx}"); + } + } + } + Ok(()) +} + +/// Builds a random list delta for `list`: every element is retained, deleted, +/// or (for children) moved to a random gap; new scalars and new Text children +/// are inserted into random gaps. Delete and insert items are randomly split. +/// Returns the diff batch and the expected list value. +fn random_list_delta( + rng: &mut StdRng, + doc: &LoroDoc, + list: &LoroMovableList, + fresh: &mut u32, +) -> (DiffBatch, Value) { + #[derive(Clone)] + enum Entry { + Scalar(LoroValue), + Child(ContainerID, Value), + NewText(ContainerID, String), + } + + let current = list.get_value(); + let current = current.as_list().unwrap(); + let deep = list.get_deep_value().to_json_value(); + let len = current.len(); + let mut keep = vec![false; len]; + let mut gaps: Vec> = vec![Vec::new(); len + 1]; + let mut edited_child = None; + for i in 0..len { + match (¤t[i], rng.gen_range(0..10)) { + (_, 0..=3) => keep[i] = true, + (LoroValue::Container(id), 4..=7) => { + let mut value = deep[i].clone(); + if id.container_type() == ContainerType::Text + && edited_child.is_none() + && rng.gen_bool(0.3) + { + edited_child = Some(id.clone()); + value = json!(format!("{}!", value.as_str().unwrap())); + } + let gap = rng.gen_range(0..=len); + gaps[gap].push((Entry::Child(id.clone(), value), rng.gen_bool(0.5))); + } + _ => {} + } + } + for _ in 0..rng.gen_range(0..4) { + *fresh += 1; + let entry = if rng.gen_bool(0.5) { + Entry::Scalar(LoroValue::from(format!("v{fresh}"))) + } else { + Entry::NewText( + ContainerID::new_normal(ID::new(1000, *fresh as i32), ContainerType::Text), + format!("n{fresh}"), + ) + }; + gaps[rng.gen_range(0..=len)].push((entry, false)); + } + + let mut items: Vec = Vec::new(); + let mut expected = Vec::new(); + let mut new_texts = Vec::new(); + for (gap, entries) in gaps.iter_mut().enumerate() { + entries.shuffle(rng); + let mut chunk: Vec = Vec::new(); + let mut chunk_is_move = false; + for (entry, is_move) in entries.drain(..) { + if !chunk.is_empty() && (rng.gen_bool(0.3) || is_move != chunk_is_move) { + items.push(ListDiffItem::Insert { + insert: std::mem::take(&mut chunk), + is_move: chunk_is_move, + }); + } + chunk_is_move = is_move; + match entry { + Entry::Scalar(v) => { + expected.push(v.to_json_value()); + chunk.push(val(v)); + } + Entry::Child(id, value) => { + expected.push(value); + chunk.push(cid(id)); + } + Entry::NewText(id, s) => { + expected.push(json!(s)); + chunk.push(cid(id.clone())); + new_texts.push((id, s)); + } + } + } + if !chunk.is_empty() { + items.push(ListDiffItem::Insert { + insert: chunk, + is_move: chunk_is_move, + }); + } + if gap < len { + if keep[gap] { + expected.push(deep[gap].clone()); + match items.last_mut() { + Some(ListDiffItem::Retain { retain }) => *retain += 1, + _ => items.push(retain(1)), + } + } else { + match items.last_mut() { + Some(ListDiffItem::Delete { delete }) if rng.gen_bool(0.5) => *delete += 1, + _ => items.push(del(1)), + } + } + } + } + + let mut batch = DiffBatch::default(); + batch.push(list.id(), Diff::List(items)).unwrap(); + for (id, s) in new_texts { + batch + .push( + id, + Diff::Text(vec![TextDelta::Insert { + insert: s, + attributes: None, + }]), + ) + .unwrap(); + } + if let Some(id) = edited_child { + let text = doc.get_text(id.clone()); + batch + .push( + id, + Diff::Text(vec![ + TextDelta::Retain { + retain: text.len_unicode(), + attributes: None, + }, + TextDelta::Insert { + insert: "!".into(), + attributes: None, + }, + ]), + ) + .unwrap(); + } + (batch, Value::Array(expected)) +} + +/// Random deltas with multi-child moves, mixed scalars, and split delete items. +/// Each applied delta must produce the expected value, and undoing/redoing the +/// whole sequence must walk back and forth through every intermediate value. +#[test] +fn random_delta_apply_undo_redo() -> LoroResult<()> { + for seed in 0..128u64 { + let mut rng = StdRng::seed_from_u64(seed); + let nested = seed % 2 == 1; + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + let list = movable_list(&doc, nested); + let mut n = 0; + for _ in 0..rng.gen_range(2..10) { + random_list_op(&mut rng, &list, &mut n); + } + doc.commit(); + + let mut undo = UndoManager::new(&doc); + undo.set_merge_interval(0); + let mut history = vec![deep_json(&doc)]; + let mut fresh = 0; + for step in 0..6 { + let version = doc.state_frontiers(); + if rng.gen_bool(0.3) { + random_list_op(&mut rng, &list, &mut n); + } else { + let (batch, expected) = random_list_delta(&mut rng, &doc, &list, &mut fresh); + doc.apply_diff(batch)?; + assert_eq!( + list.get_deep_value().to_json_value(), + expected, + "seed={seed} step={step}" + ); + } + doc.commit(); + // A no-op delta creates no change and no undo step + if doc.state_frontiers() != version { + history.push(deep_json(&doc)); + } + } + + for expected in history.iter().rev().skip(1) { + assert!(undo.undo()?, "seed={seed}"); + assert_eq!(&deep_json(&doc), expected, "undo seed={seed}"); + } + assert!(!undo.can_undo()); + for expected in history.iter().skip(1) { + assert!(undo.redo()?, "seed={seed}"); + assert_eq!(&deep_json(&doc), expected, "redo seed={seed}"); + } + } + Ok(()) +} + +/// Two peers mix local ops, revert-style `apply_diff`, sync, and undo/redo. +/// Undo is not expected to restore an exact value under concurrency, but it +/// must not panic or fail, and the peers must converge. +#[test] +fn random_concurrent_apply_diff_undo_redo_does_not_panic() -> LoroResult<()> { + for seed in 0..48u64 { + let mut rng = StdRng::seed_from_u64(seed); + let docs = [LoroDoc::new(), LoroDoc::new()]; + docs[0].set_peer_id(1)?; + docs[1].set_peer_id(2)?; + let mut n = 0; + { + let list = movable_list(&docs[0], false); + for _ in 0..6 { + random_list_op(&mut rng, &list, &mut n); + } + docs[0].commit(); + docs[1].import(&docs[0].export(ExportMode::all_updates())?)?; + } + let mut undos = [UndoManager::new(&docs[0]), UndoManager::new(&docs[1])]; + undos[0].set_merge_interval(0); + undos[1].set_merge_interval(0); + let mut versions = [ + vec![docs[0].state_frontiers()], + vec![docs[1].state_frontiers()], + ]; + + for _ in 0..40 { + let p = rng.gen_range(0..2); + let doc = &docs[p]; + match rng.gen_range(0..10) { + 0..=3 => { + let list = movable_list(doc, false); + random_list_op(&mut rng, &list, &mut n); + doc.commit(); + } + 4 | 5 => { + let target = versions[p].choose(&mut rng).unwrap().clone(); + let diff = doc.diff(&doc.state_frontiers(), &target)?; + doc.apply_diff(diff)?; + doc.commit(); + } + 6 => { + undos[p].undo()?; + } + 7 => { + undos[p].redo()?; + } + _ => { + let other = &docs[1 - p]; + doc.import(&other.export(ExportMode::updates(&doc.oplog_vv()))?)?; + } + } + versions[p].push(doc.state_frontiers()); + } + + docs[1].import(&docs[0].export(ExportMode::all_updates())?)?; + docs[0].import(&docs[1].export(ExportMode::all_updates())?)?; + assert_eq!(deep_json(&docs[0]), deep_json(&docs[1]), "seed={seed}"); + } + Ok(()) +} diff --git a/crates/loro/tests/perf_movable_list_apply_diff.rs b/crates/loro/tests/perf_movable_list_apply_diff.rs new file mode 100644 index 000000000..02d9a32af --- /dev/null +++ b/crates/loro/tests/perf_movable_list_apply_diff.rs @@ -0,0 +1,147 @@ +use loro::{ + event::{Diff, DiffBatch, ListDiffItem}, + ContainerID, ContainerTrait, ContainerType, LoroDoc, LoroMovableList, LoroText, LoroValue, + UndoManager, ValueOrContainer, ID, +}; +use std::time::{Duration, Instant}; + +fn cid(id: ContainerID) -> ValueOrContainer { + ValueOrContainer::Value(LoroValue::Container(id)) +} + +/// Builds `[children..., scalars...]` or `[scalars..., children...]`. +fn build( + n: usize, + children: usize, + children_first: bool, +) -> (LoroDoc, LoroMovableList, Vec) { + let doc = LoroDoc::new(); + doc.set_peer_id(1).unwrap(); + let list = doc.get_movable_list("l"); + let mut ids = Vec::new(); + let push_children = |ids: &mut Vec| { + for i in 0..children { + let t = list.push_container(LoroText::new()).unwrap(); + t.insert(0, &i.to_string()).unwrap(); + ids.push(t.id()); + } + }; + if children_first { + push_children(&mut ids); + } + for i in 0..n - children { + list.push(i as i64).unwrap(); + } + if !children_first { + push_children(&mut ids); + } + doc.commit(); + (doc, list, ids) +} + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +fn bench(name: &str, n: usize, setup: impl Fn() -> (LoroDoc, LoroMovableList, Vec)) { + let mut apply = Vec::new(); + let mut undo = Vec::new(); + for _ in 0..5 { + let (doc, list, items) = setup(); + let mut undo_manager = UndoManager::new(&doc); + undo_manager.set_merge_interval(0); + let mut batch = DiffBatch::default(); + batch.push(list.id(), Diff::List(items)).unwrap(); + let start = Instant::now(); + doc.apply_diff(batch).unwrap(); + doc.commit(); + apply.push(start.elapsed()); + let start = Instant::now(); + assert!(undo_manager.undo().unwrap()); + undo.push(start.elapsed()); + } + println!( + "{name:<24} n={n:<7} apply_diff={:>10.3?} undo={:>10.3?}", + median(apply), + median(undo) + ); +} + +#[test] +#[ignore] +fn perf_movable_list_apply_diff() { + // Run with: + // cargo test --release -p loro --test perf_movable_list_apply_diff -- --ignored --nocapture + let n: usize = std::env::var("LORO_PERF_N") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100_000); + + bench("move-child-to-end", n, || { + let (doc, list, ids) = build(n, 1, true); + let items = vec![ + ListDiffItem::Delete { delete: 1 }, + ListDiffItem::Retain { retain: n - 1 }, + ListDiffItem::Insert { + insert: vec![cid(ids[0].clone())], + is_move: false, + }, + ]; + (doc, list, items) + }); + bench("move-child-to-front", n, || { + let (doc, list, ids) = build(n, 1, false); + let items = vec![ + ListDiffItem::Insert { + insert: vec![cid(ids[0].clone())], + is_move: false, + }, + ListDiffItem::Retain { retain: n - 1 }, + ListDiffItem::Delete { delete: 1 }, + ]; + (doc, list, items) + }); + bench("insert-child-tail", n, || { + let (doc, list, _) = build(n, 0, true); + let new_id = ContainerID::new_normal(ID::new(9, 0), ContainerType::Text); + let items = vec![ + ListDiffItem::Retain { retain: n }, + ListDiffItem::Insert { + insert: vec![cid(new_id)], + is_move: false, + }, + ]; + (doc, list, items) + }); + bench("delete-scalars", n, || { + let (doc, list, _) = build(n, 0, true); + (doc, list, vec![ListDiffItem::Delete { delete: n }]) + }); + for k in [1_000, 5_000] { + bench(&format!("rotate-{k}-children"), n, || { + let (doc, list, ids) = build(n, k, true); + let mut rotated: Vec<_> = ids[1..].iter().cloned().map(cid).collect(); + rotated.push(cid(ids[0].clone())); + let items = vec![ + ListDiffItem::Delete { delete: k }, + ListDiffItem::Insert { + insert: rotated, + is_move: false, + }, + ]; + (doc, list, items) + }); + bench(&format!("reverse-{k}-children"), n, || { + let (doc, list, ids) = build(n, k, true); + let items = vec![ + ListDiffItem::Delete { delete: k }, + ListDiffItem::Insert { + insert: ids.iter().rev().cloned().map(cid).collect(), + is_move: false, + }, + ]; + (doc, list, items) + }); + } +}