Implement delta on the server, use compact representation when transmitting changes

This commit is contained in:
Jonatan Kłosko 2021-01-20 02:24:58 +01:00
parent fa115e045b
commit dbbb41992e
14 changed files with 678 additions and 70 deletions

View file

@ -120,12 +120,16 @@ class AwaitingWithBuffer {
onServerDelta(delta) {
// We consider the incoming delta to happen first
// (because that's the case from the server's perspective).
const deltaPrime = this.awaitedDelta.compose(this.buffer).transform(delta, "right");
this.client.applyDelta(deltaPrime);
// Delta transformed against awaitedDelta
const deltaPrime = this.awaitedDelta.transform(delta, "right");
// Delta transformed against both awaitedDelta and the buffer (appropriate for applying to the editor)
const deltaBis = this.buffer.transform(deltaPrime, "right");
this.client.applyDelta(deltaBis);
const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left");
const bufferPrime = this.awaitedDelta
.transform(delta, "right")
.transform(this.buffer, "left");
const bufferPrime = deltaPrime.transform(this.buffer, "left");
return new AwaitingWithBuffer(this.client, awaitedDeltaPrime, bufferPrime);
}

View file

@ -12,8 +12,8 @@ export default class HookServerAdapter {
this._onDelta = null;
this._onAcknowledgement = null;
this.hook.handleEvent(`cell_delta:${this.cellId}`, (delta) => {
this._onDelta && this._onDelta(new Delta(delta.ops));
this.hook.handleEvent(`cell_delta:${this.cellId}`, ({ delta }) => {
this._onDelta && this._onDelta(Delta.fromCompressed(delta));
});
this.hook.handleEvent(`cell_acknowledgement:${this.cellId}`, () => {
@ -41,7 +41,7 @@ export default class HookServerAdapter {
sendDelta(delta, revision) {
this.hook.pushEvent("cell_delta", {
cell_id: this.cellId,
delta,
delta: delta.toCompressed(),
revision,
});
}

View file

@ -1,5 +1,5 @@
import monaco from "./monaco";
import Delta from "../lib/delta";
import Delta, { isDelete, isInsert, isRetain } from "../lib/delta";
/**
* Encapsulates logic related to getting/applying changes to the editor.
@ -69,11 +69,11 @@ export default class MonacoEditorAdapter {
let index = 0;
delta.ops.forEach((op) => {
if (typeof op.retain === "number") {
if (isRetain(op)) {
index += op.retain;
}
if (typeof op.insert === "string") {
if (isInsert(op)) {
const start = model.getPositionAt(index);
operations.push({
@ -88,7 +88,7 @@ export default class MonacoEditorAdapter {
});
}
if (typeof op.delete === "number") {
if (isDelete(op)) {
const start = model.getPositionAt(index);
const end = model.getPositionAt(index + op.delete);

View file

@ -32,7 +32,7 @@ export default class Delta {
return this.append({ retain: length });
}
/**
/**
* Appends an insert operation.
*/
insert(text) {
@ -144,9 +144,10 @@ export default class Delta {
* so that does effectively what the original delta B meant to do.
* In our case that would be `A.transform(B, "right")`.
*
* Transformation should work both ways satisfying the property (assuming B is considered to happen first):
* Transformation should work both ways satisfying the property:
* (assuming B is considered to have happened first)
*
* `A.concat(A.transform(B, "right")) = B.concat(B.transform(A, "left"))`
* `A.compose(A.transform(B, "right")) = B.compose(B.transform(A, "left"))`
*
* In Git analogy this can be thought of as rebasing branch B onto branch A.
*
@ -155,7 +156,9 @@ export default class Delta {
*/
transform(other, priority) {
if (priority !== "left" && priority !== "right") {
throw new Error(`Invalid priority "${priority}", should be either "left" or "right"`)
throw new Error(
`Invalid priority "${priority}", should be either "left" or "right"`
);
}
const thisIter = new Iterator(this.ops);
@ -163,7 +166,10 @@ export default class Delta {
const delta = new Delta();
while (thisIter.hasNext() || otherIter.hasNext()) {
if (isInsert(thisIter.peek()) && (!isInsert(otherIter.peek()) || priority === "left")) {
if (
isInsert(thisIter.peek()) &&
(!isInsert(otherIter.peek()) || priority === "left")
) {
const insertLength = operationLength(thisIter.next());
delta.retain(insertLength);
} else if (isInsert(otherIter.peek())) {
@ -195,6 +201,34 @@ export default class Delta {
return this;
}
toCompressed() {
return this.ops.map((op) => {
if (isInsert(op)) {
return op.insert;
} else if (isRetain(op)) {
return op.retain;
} else if (isDelete(op)) {
return -op.delete;
}
throw new Error(`Invalid operation ${op}`);
});
}
static fromCompressed(list) {
return list.reduce((delta, compressedOp) => {
if (typeof compressedOp === "string") {
return delta.insert(compressedOp);
} else if (typeof compressedOp === "number" && compressedOp >= 0) {
return delta.retain(compressedOp);
} else if (typeof compressedOp === "number" && compressedOp < 0) {
return delta.delete(-compressedOp);
}
throw new Error(`Invalid compressed operation ${compressedOp}`);
}, new this());
}
}
/**
@ -236,7 +270,7 @@ class Iterator {
return { insert: nextOp.insert.substr(offset, length) };
}
} else {
return { retain: Infinity };
return { retain: length };
}
}
@ -267,14 +301,14 @@ function operationLength(op) {
}
}
function isInsert(op) {
export function isInsert(op) {
return typeof op.insert === "string";
}
function isRetain(op) {
export function isRetain(op) {
return typeof op.retain === "number";
}
function isDelete(op) {
export function isDelete(op) {
return typeof op.delete === "number";
}

View file

@ -165,7 +165,7 @@ describe('Delta', () => {
});
it('insert against retain', () => {
const a = new Delta().retain(1);
const a = new Delta().retain(1).insert('A');
const b = new Delta().insert('B');
const bPrime = new Delta().insert('B');
expect(a.transform(b, "right")).toEqual(bPrime);
@ -211,7 +211,7 @@ describe('Delta', () => {
.delete(1);
expect(a.transform(b, "right")).toEqual(bPrimeAssumingBFirst);
expect(b.transform(a, "right")).toEqual(aPrimeAssumingBFirst);
expect(b.transform(a, "left")).toEqual(aPrimeAssumingBFirst);
});
it('conflicting appends', () => {
@ -229,7 +229,7 @@ describe('Delta', () => {
const bPrime = new Delta().retain(5).insert('bb');
const aPrime = new Delta().insert('aa');
expect(a.transform(b, "right")).toEqual(bPrime);
expect(b.transform(a, "right")).toEqual(aPrime);
expect(b.transform(a, "left")).toEqual(aPrime);
});
it('trailing deletes with differing lengths', () => {
@ -238,7 +238,7 @@ describe('Delta', () => {
const bPrime = new Delta().delete(2);
const aPrime = new Delta();
expect(a.transform(b, "right")).toEqual(bPrime);
expect(b.transform(a, "right")).toEqual(aPrime);
expect(b.transform(a, "left")).toEqual(aPrime);
});
it('immutability', () => {

107
lib/live_book/delta.ex Normal file
View file

@ -0,0 +1,107 @@
defmodule LiveBook.Delta do
defstruct ops: []
alias LiveBook.Delta.{Operation, Transformation}
@type t :: %__MODULE__{
ops: list(Operation.t())
}
@doc """
Creates a new delta, optionally taking a list of operations.
"""
def new(opts \\ [])
def new([]), do: %__MODULE__{}
def new(ops), do: Enum.reduce(ops, new(), &append(&2, &1))
def insert(delta, text) do
append(delta, Operation.insert(text))
end
def retain(delta, length) do
append(delta, Operation.retain(length))
end
def delete(delta, length) do
append(delta, Operation.delete(length))
end
def append(delta, op) do
Map.update!(delta, :ops, fn ops ->
ops
|> Enum.reverse()
|> compact(op)
|> Enum.reverse()
end)
end
defp compact(ops, {:insert, ""}), do: ops
defp compact(ops, {:retain, 0}), do: ops
defp compact(ops, {:delete, 0}), do: ops
defp compact([], new_op), do: [new_op]
defp compact([{:delete, _} = del | ops_remainder], {:insert, _} = ins) do
ops_remainder
|> compact(ins)
|> compact(del)
end
defp compact([{:retain, len_a} | ops_remainder], {:retain, len_b}) do
[Operation.retain(len_a + len_b) | ops_remainder]
end
defp compact([{:insert, text_a} | ops_remainder], {:insert, text_b}) do
[Operation.insert(text_a <> text_b) | ops_remainder]
end
defp compact([{:delete, len_a} | ops_remainder], {:delete, len_b}) do
[Operation.delete(len_a + len_b) | ops_remainder]
end
defp compact(ops, new_op), do: [new_op | ops]
def trim(%TextDelta{ops: []} = delta), do: delta
def trim(delta) do
case List.last(delta.ops) do
{:retain, _} ->
Map.update!(delta, :ops, fn ops ->
ops |> Enum.reverse() |> tl() |> Enum.reverse()
end)
_ ->
delta
end
end
def apply_to_text(delta, text) do
apply_ops_to_text(delta.ops, text)
end
defp apply_ops_to_text([], text), do: text
defp apply_ops_to_text([{:retain, n} | ops], text) do
{left, right} = String.split_at(text, n)
left <> apply_ops_to_text(ops, right)
end
defp apply_ops_to_text([{:insert, inserted} | ops], text) do
inserted <> apply_ops_to_text(ops, text)
end
defp apply_ops_to_text([{:delete, n} | ops], text) do
apply_ops_to_text(ops, String.slice(text, n..-1))
end
def to_compressed(delta) do
Enum.map(delta.ops, &Operation.to_compressed/1)
end
def from_compressed(list) do
list
|> Enum.map(&Operation.from_compressed/1)
|> new()
end
defdelegate transform(left, right, priority), to: Transformation
end

View file

@ -0,0 +1,60 @@
defmodule LiveBook.Delta.Iterator do
alias LiveBook.Delta.Operation
@typedoc """
Individual set of operations.
"""
@type set :: [Operation.t()]
@typedoc """
Two sets of operations to iterate.
"""
@type sets :: {set, set}
@typedoc """
A tuple representing the new head and tail operations of the two operation
sets being iterated over.
"""
@type cycle :: {set_split, set_split}
@typedoc """
A set's next scanned full or partial operation, and its resulting tail set.
"""
@type set_split :: {Operation.t() | nil, set}
@doc """
Generates next cycle by iterating over given sets of operations.
"""
@spec next(sets) :: cycle
def next(sets)
def next({[], []}) do
{{nil, []}, {nil, []}}
end
def next({[], [head_b | tail_b]}) do
{{nil, []}, {head_b, tail_b}}
end
def next({[head_a | tail_a], []}) do
{{head_a, tail_a}, {nil, []}}
end
def next({[head_a | tail_a], [head_b | tail_b]}) do
len_a = Operation.length(head_a)
len_b = Operation.length(head_b)
cond do
len_a > len_b ->
{head_a, remainder_a} = Operation.slice(head_a, len_b)
{{head_a, [remainder_a | tail_a]}, {head_b, tail_b}}
len_a < len_b ->
{head_b, remainder_b} = Operation.slice(head_b, len_a)
{{head_a, tail_a}, {head_b, [remainder_b | tail_b]}}
true ->
{{head_a, tail_a}, {head_b, tail_b}}
end
end
end

View file

@ -0,0 +1,42 @@
defmodule LiveBook.Delta.Operation do
@type t :: insert | retain | delete
@type insert :: {:insert, String.t()}
@type retain :: {:retain, non_neg_integer()}
@type delete :: {:delete, non_neg_integer()}
def insert(text), do: {:insert, text}
def retain(length), do: {:retain, length}
def delete(length), do: {:delete, length}
def type({type, _}), do: type
def length({:insert, text}), do: String.length(text)
def length({:retain, length}), do: length
def length({:delete, length}), do: length
def slice(op, idx)
def slice({:insert, text}, idx) do
{part_one, part_two} = String.split_at(text, idx)
{insert(part_one), insert(part_two)}
end
def slice({:retain, length}, idx) do
{retain(idx), retain(length - idx)}
end
def slice({:delete, length}, idx) do
{delete(idx), delete(length - idx)}
end
def to_compressed({:insert, text}), do: text
def to_compressed({:retain, length}), do: length
def to_compressed({:delete, length}), do: -length
def from_compressed(text) when is_binary(text), do: {:insert, text}
def from_compressed(length) when is_integer(length) and length >= 0, do: {:retain, length}
def from_compressed(length) when is_integer(length) and length < 0, do: {:delete, -length}
end

View file

@ -0,0 +1,135 @@
defmodule LiveBook.Delta.Transformation do
alias LiveBook.Delta
alias LiveBook.Delta.{Operation, Iterator}
@type priority :: :left | :right
def transform(left, right, priority) do
{left.ops, right.ops}
|> Iterator.next()
|> do_transform(priority, Delta.new())
|> Delta.trim()
end
defp do_transform({{_, _}, {nil, _}}, _, result) do
result
end
defp do_transform({{nil, _}, {op_b, remainder_b}}, _, result) do
Enum.reduce([op_b | remainder_b], result, &Delta.append(&2, &1))
end
defp do_transform(
{{{:insert, _} = ins_a, remainder_a}, {{:insert, _} = ins_b, remainder_b}},
:left,
result
) do
retain = make_retain(ins_a)
{remainder_a, [ins_b | remainder_b]}
|> Iterator.next()
|> do_transform(:left, Delta.append(result, retain))
end
defp do_transform(
{{{:insert, _} = ins_a, remainder_a}, {{:insert, _} = ins_b, remainder_b}},
:right,
result
) do
{[ins_a | remainder_a], remainder_b}
|> Iterator.next()
|> do_transform(:right, Delta.append(result, ins_b))
end
defp do_transform(
{{{:insert, _} = ins, remainder_a}, {{:retain, _} = ret, remainder_b}},
priority,
result
) do
retain = make_retain(ins)
{remainder_a, [ret | remainder_b]}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, retain))
end
defp do_transform(
{{{:insert, _} = ins, remainder_a}, {{:delete, _} = del, remainder_b}},
priority,
result
) do
retain = make_retain(ins)
{remainder_a, [del | remainder_b]}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, retain))
end
defp do_transform(
{{{:delete, _} = del, remainder_a}, {{:insert, _} = ins, remainder_b}},
priority,
result
) do
{[del | remainder_a], remainder_b}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, ins))
end
defp do_transform(
{{{:delete, _}, remainder_a}, {{:retain, _}, remainder_b}},
priority,
result
) do
{remainder_a, remainder_b}
|> Iterator.next()
|> do_transform(priority, result)
end
defp do_transform(
{{{:delete, _}, remainder_a}, {{:delete, _}, remainder_b}},
priority,
result
) do
{remainder_a, remainder_b}
|> Iterator.next()
|> do_transform(priority, result)
end
defp do_transform(
{{{:retain, _} = ret, remainder_a}, {{:insert, _} = ins, remainder_b}},
priority,
result
) do
{[ret | remainder_a], remainder_b}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, ins))
end
defp do_transform(
{{{:retain, _} = ret_a, remainder_a}, {{:retain, _}, remainder_b}},
priority,
result
) do
retain = make_retain(ret_a)
{remainder_a, remainder_b}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, retain))
end
defp do_transform(
{{{:retain, _}, remainder_a}, {{:delete, _} = del, remainder_b}},
priority,
result
) do
{remainder_a, remainder_b}
|> Iterator.next()
|> do_transform(priority, Delta.append(result, del))
end
defp make_retain(op) do
op
|> Operation.length()
|> Operation.retain()
end
end

View file

@ -1,34 +0,0 @@
defmodule LiveBook.DeltaUtils do
def apply_delta_to_text(text, delta) do
apply_operations_to_text(text, delta.ops)
end
defp apply_operations_to_text(text, []), do: text
defp apply_operations_to_text(text, [%{retain: n} | ops]) do
{left, right} = String.split_at(text, n)
left <> apply_operations_to_text(right, ops)
end
defp apply_operations_to_text(text, [%{insert: inserted} | ops]) do
inserted <> apply_operations_to_text(text, ops)
end
defp apply_operations_to_text(text, [%{delete: n} | ops]) do
apply_operations_to_text(String.slice(text, n..-1), ops)
end
def delta_to_map(delta) do
%{ops: delta.ops}
end
def delta_from_map(%{"ops" => ops}) do
ops
|> Enum.map(&parse_operation/1)
|> TextDelta.new()
end
defp parse_operation(%{"insert" => insert}), do: %{insert: insert}
defp parse_operation(%{"retain" => retain}), do: %{retain: retain}
defp parse_operation(%{"delete" => delete}), do: %{delete: delete}
end

View file

@ -15,7 +15,7 @@ defmodule LiveBook.Session do
use GenServer, restart: :temporary
alias LiveBook.Session.Data
alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook}
alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook, Delta}
alias LiveBook.Notebook.{Cell, Section}
@type state :: %{
@ -135,7 +135,7 @@ defmodule LiveBook.Session do
@doc """
Asynchronously sends a cell delta to apply to the server.
"""
@spec apply_cell_delta(id(), pid(), Cell.id(), TextDelta.t(), Data.cell_revision()) :: :ok
@spec apply_cell_delta(id(), pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok
def apply_cell_delta(session_id, from, cell_id, delta, revision) do
GenServer.cast(name(session_id), {:apply_cell_delta, from, cell_id, delta, revision})
end

View file

@ -23,7 +23,7 @@ defmodule LiveBook.Session.Data do
:deleted_cells
]
alias LiveBook.{Notebook, Evaluator, DeltaUtils}
alias LiveBook.{Notebook, Evaluator, Delta}
alias LiveBook.Notebook.{Cell, Section}
@type t :: %__MODULE__{
@ -44,7 +44,7 @@ defmodule LiveBook.Session.Data do
validity_status: cell_validity_status(),
evaluation_status: cell_evaluation_status(),
revision: cell_revision(),
deltas: list(TextDelta.t()),
deltas: list(Delta.t()),
evaluated_at: DateTime.t()
}
@ -66,13 +66,13 @@ defmodule LiveBook.Session.Data do
| {:cancel_cell_evaluation, Cell.id()}
| {:set_notebook_name, String.t()}
| {:set_section_name, Section.id(), String.t()}
| {:apply_cell_delta, pid(), Cell.id(), TextDelta.t(), cell_revision()}
| {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()}
@type action ::
{:start_evaluation, Cell.t(), Section.t()}
| {:stop_evaluation, Section.t()}
| {:forget_evaluation, Cell.t(), Section.t()}
| {:broadcast_delta, pid(), Cell.t(), TextDelta.t()}
| {:broadcast_delta, pid(), Cell.t(), Delta.t()}
@doc """
Returns a fresh notebook session state.
@ -431,10 +431,10 @@ defmodule LiveBook.Session.Data do
rebased_new_delta =
Enum.reduce(deltas_ahead, delta, fn delta_ahead, rebased_new_delta ->
TextDelta.transform(delta_ahead, rebased_new_delta, :left)
Delta.transform(delta_ahead, rebased_new_delta, :left)
end)
new_source = DeltaUtils.apply_delta_to_text(cell.source, rebased_new_delta)
new_source = Delta.apply_to_text(rebased_new_delta, cell.source)
data_actions
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &%{&1 | source: new_source}))

View file

@ -1,7 +1,7 @@
defmodule LiveBookWeb.SessionLive do
use LiveBookWeb, :live_view
alias LiveBook.{SessionSupervisor, Session, DeltaUtils}
alias LiveBook.{SessionSupervisor, Session, Delta}
@impl true
def mount(%{"id" => session_id}, _session, socket) do
@ -141,7 +141,7 @@ defmodule LiveBookWeb.SessionLive do
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision},
socket
) do
delta = DeltaUtils.delta_from_map(delta)
delta = Delta.from_compressed(delta)
Session.apply_cell_delta(socket.assigns.session_id, self(), cell_id, delta, revision)
{:noreply, socket}
@ -177,7 +177,7 @@ defmodule LiveBookWeb.SessionLive do
if from == self() do
push_event(socket, "cell_acknowledgement:#{cell.id}", %{})
else
push_event(socket, "cell_delta:#{cell.id}", DeltaUtils.delta_to_map(delta))
push_event(socket, "cell_delta:#{cell.id}", %{delta: Delta.to_compressed(delta)})
end
end

View file

@ -0,0 +1,260 @@
defmodule LiveBook.Delta.TransformationText do
use ExUnit.Case, async: true
alias LiveBook.Delta
describe "transform" do
test "insert against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.insert("B")
b_prime_assuming_a_a =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime_assuming_b_a =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :left) == b_prime_assuming_a_a
assert Delta.transform(a, b, :right) == b_prime_assuming_b_a
end
test "retain against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.retain(2)
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.delete(1)
b_prime =
Delta.new()
|> Delta.retain(1)
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime
end
test "insert against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "retain against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.retain(1)
b_prime = Delta.new()
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.delete(1)
b_prime = Delta.new()
assert Delta.transform(a, b, :right) == b_prime
end
test "insert against retain" do
a =
Delta.new()
|> Delta.retain(1)
# Add insert, so that trailing retain is not trimmed
|> Delta.insert("A")
b =
Delta.new()
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "retain against retain" do
a =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("A")
b =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against retain" do
a =
Delta.new()
|> Delta.retain(1)
b =
Delta.new()
|> Delta.delete(1)
b_prime =
Delta.new()
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime
end
test "alternating edits" do
a =
Delta.new()
|> Delta.retain(2)
|> Delta.insert("si")
|> Delta.delete(5)
b =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("e")
|> Delta.delete(5)
|> Delta.retain(1)
|> Delta.insert("ow")
b_prime_assuming_b_first =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("e")
|> Delta.delete(1)
|> Delta.retain(2)
|> Delta.insert("ow")
a_prime_assuming_b_first =
Delta.new()
|> Delta.retain(2)
|> Delta.insert("si")
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
assert Delta.transform(b, a, :left) == a_prime_assuming_b_first
end
test "conflicting appends" do
a =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("aa")
b =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
b_prime_assuming_b_first =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
a_prime_assuming_b_first =
Delta.new()
|> Delta.retain(5)
|> Delta.insert("aa")
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
assert Delta.transform(b, a, :left) == a_prime_assuming_b_first
end
test "prepend and append" do
a =
Delta.new()
|> Delta.insert("aa")
b =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
b_prime =
Delta.new()
|> Delta.retain(5)
|> Delta.insert("bb")
a_prime =
Delta.new()
|> Delta.insert("aa")
assert Delta.transform(a, b, :right) == b_prime
assert Delta.transform(b, a, :left) == a_prime
end
test "trailing deletes with differing lengths" do
a =
Delta.new()
|> Delta.retain(2)
|> Delta.delete(1)
b =
Delta.new()
|> Delta.delete(3)
b_prime =
Delta.new()
|> Delta.delete(2)
a_prime = Delta.new()
assert Delta.transform(a, b, :right) == b_prime
assert Delta.transform(b, a, :left) == a_prime
end
end
end