substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.
293 lines (261 loc) • 10.3 kB
JavaScript
import { module } from 'substance-test'
import { ArrayOperation } from 'substance'
const test = module('ArrayOperation')
function checkArrayOperationTransform(t, a, b, input, expected) {
let ops = ArrayOperation.transform(a, b);
let output = ops[1].apply(a.apply(input.slice(0)));
t.deepEqual(output, expected, `(b' o a)('${JSON.stringify(input)}') == '${JSON.stringify(expected)}' with a=${a.toString()}, b'=${ops[1].toString()}`);
output = ops[0].apply(b.apply(input.slice(0)));
t.deepEqual(output, expected, `(a' o b)('${JSON.stringify(input)}') == '${JSON.stringify(expected)}' with b=${b.toString()}, a'=${ops[0].toString()}`);
}
test("Insert element", (t) => {
let arr = [1,2,4]
let expected = [1,2,3,4]
let op = ArrayOperation.Insert(2, 3)
op.apply(arr)
t.deepEqual(arr, expected, 'Should insert element.')
t.end()
})
test("Insert element after last position", (t) => {
let arr = [1,2,3]
let expected = [1,2,3,4]
let op = ArrayOperation.Insert(arr.length, 4)
op.apply(arr)
t.deepEqual(arr, expected, 'Should append element.')
t.end()
})
test("Delete element", (t) => {
let arr = [1,2,3]
let expected = [1,3]
let op = ArrayOperation.Delete(1, 2)
op.apply(arr)
t.deepEqual(arr, expected, 'Should delete element.')
t.end()
})
test("Create operation with invalid data", (t) => {
t.throws(function() {
new ArrayOperation()
}, "Should throw if no data given.")
t.throws(function() {
new ArrayOperation({ type: 'foo' })
}, "Should throw for invalid type.")
t.throws(function() {
new ArrayOperation({ type: ArrayOperation.INSERT, value: 1})
}, "Should throw for missing position.")
t.throws(function() {
new ArrayOperation({ type: ArrayOperation.INSERT, pos: -1, value: 1})
}, "Should throw for position < 0.")
t.end()
})
test("Operation can be NOP", (t) => {
let op = new ArrayOperation({type: ArrayOperation.NOP})
t.ok(op.isNOP(), 'Operation should be NOP')
t.end()
})
test("Apply operation on too short array.", (t) => {
let arr = [1,2,3]
let op = ArrayOperation.Insert(4, 5)
t.throws(function() {
op.apply(arr)
}, "Should throw if applying insert operation out-of-bounds")
op = ArrayOperation.Delete(4, 5)
t.throws(function() {
op.apply(arr)
}, "Should throw if applying delete operation out-of-bounds")
t.end()
})
// Note: it is better to fail in such cases, as this is an indicator for other greater problems.
test("Apply delete operation on wrong array.", (t) => {
let arr = [1,2,3]
let op = ArrayOperation.Delete(2, 4)
t.throws(function() {
op.apply(arr)
}, "Should throw if applying delete operation with wrong value")
t.end()
})
test("JSON de-/serialisation", (t) => {
let op = ArrayOperation.Delete(1,2)
let out = op.toJSON()
t.equal(out.type, ArrayOperation.DELETE)
t.equal(out.pos, 1)
t.equal(out.val, 2)
op = ArrayOperation.fromJSON(out)
t.ok(op.isDelete())
t.equal(op.getOffset(), 1)
t.equal(op.getValue(), 2)
op = new ArrayOperation({type: ArrayOperation.NOP})
out = op.toJSON()
t.deepEqual(out, {type: ArrayOperation.NOP})
t.end()
})
// Insert-Insert Transformations
// --------
// Cases:
// 1. `a < b`: operations should not be affected
// 2. `b < a`: dito
// 3. `a == b`: result depends on preference (first applied)
test("Transformation: a=Insert, b=Insert, a < b and b < a", (t) => {
let input = [1,3,5]
let expected = [1,2,3,4,5]
let a = ArrayOperation.Insert(1, 2)
let b = ArrayOperation.Insert(2, 4)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
// Example:
// A = [1,4], a = [+, 1, 2], b = [+, 1, 3]
// A - a -> [1, 2, 4] - b' -> [1,2,3,4] => b'= [+, 2, 3], transform(a, b) = [a, b']
// A - b -> [1, 3, 4] - a' -> [1,3,2,4] => a'= [+, 2, 2], transform(b, a) = [a', b]
test("Transformation: a=Insert, b=Insert, a == b", (t) => {
let input = [1,4]
let expected = [1,2,3,4]
let expected_2 = [1,3,2,4]
let a = ArrayOperation.Insert(1, 2)
let b = ArrayOperation.Insert(1, 3)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected_2)
t.end()
})
// Delete-Delete Transformations
// --------
// Cases:
// 1. `a < b`: operations should not be affected
// 2. `b < a`: dito
// 3. `a == b`: second operation should not have an effect
// user should be noticed about conflict
test("Transformation: a=Delete, b=Delete (1,2), a < b and b < a", (t) => {
let input = [1,2,3,4,5]
let expected = [1,3,5]
let a = ArrayOperation.Delete(1, 2)
let b = ArrayOperation.Delete(3, 4)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
test("Transformation: a=Delete, b=Delete (3), a == b", (t) => {
let input = [1,2,3]
let expected = [1,3]
let a = ArrayOperation.Delete(1, 2)
let b = ArrayOperation.Delete(1, 2)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
// Insert-Delete Transformations
// --------
// Cases: (a = insertion, b = deletion)
// 1. `a < b`: b must be shifted right
// 2. `b < a`: a must be shifted left
// 3. `a == b`: ???
// A = [1,3,4,5], a = [+, 1, 2], b = [-, 2, 4]
// A - a -> [1,2,3,4,5] - b' -> [1,2,3,5] => b'= [-, 3, 4]
// A - b -> [1,3,5] - a' -> [1,2,3,5] => a'= [+, 1, 2] = a
test("Transformation: a=Insert, b=Delete (1), a < b", (t) => {
let input = [1,3,4,5]
let expected = [1,2,3,5]
let a = ArrayOperation.Insert(1, 2)
let b = ArrayOperation.Delete(2, 4)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
// A = [1,2,3,5], a = [+,3,4], b = [-,1,2]
// A - a -> [1,2,3,4,5] - b' -> [1,3,4,5] => b'= [-,1,2] = b
// A - b -> [1,3,5] - a' -> [1,3,4,5] => a'= [+,2,4]
test("Transformation: a=Insert, b=Delete (2), b < a", (t) => {
let input = [1,2,3,5]
let expected = [1,3,4,5]
let a = ArrayOperation.Insert(3, 4)
let b = ArrayOperation.Delete(1, 2)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
// A = [1,2,3], a = [+,1,4], b = [-,1,2]
// A - a -> [1,4,2,3] - b' -> [1,4,3] => b'= [-,2,2]
// A - b -> [1,3] - a' -> [1,4,3] => a'= [+,1,4] = a
test("Transformation: a=Insert, b=Delete (3), a == b", (t) => {
let input = [1,2,3]
let expected = [1,4,3]
let a = ArrayOperation.Insert(1, 4)
let b = ArrayOperation.Delete(1, 2)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
test("Transformation: a=NOP || b=NOP", (t) => {
let a = new ArrayOperation.Insert(1, 4)
let b = new ArrayOperation({type: ArrayOperation.NOP})
let tr = ArrayOperation.transform(a, b)
t.deepEqual(tr[0].toJSON(), a.toJSON())
t.deepEqual(tr[1].toJSON(), b.toJSON())
tr = ArrayOperation.transform(b, a)
t.deepEqual(tr[0].toJSON(), b.toJSON())
t.deepEqual(tr[1].toJSON(), a.toJSON())
t.end()
})
test("Inverting operations", (t) => {
let op = ArrayOperation.Insert(1, 4)
let inverse = op.invert()
t.ok(inverse.isDelete(), 'Inverse of an insert op should be a delete op.')
t.equal(inverse.getOffset(), op.getOffset(), 'Offset of inverted op should be the same.')
t.equal(inverse.getValue(), op.getValue(), 'Value of inverted op should be the same.')
op = ArrayOperation.Delete(2, 3)
inverse = op.invert()
t.ok(inverse.isInsert(), 'Inverse of a delete op should be an insert op.')
t.equal(inverse.getOffset(), op.getOffset(), 'Offset of inverted op should be the same.')
t.equal(inverse.getValue(), op.getValue(), 'Value of inverted op should be the same.')
op = new ArrayOperation({type: ArrayOperation.NOP})
inverse = op.invert()
t.ok(inverse.isNOP(), 'Inverse of a nop is a nop.')
t.end()
})
test("Transformations can be done inplace (optimzation for internal use)", (t) => {
let a = ArrayOperation.Insert(2, 3)
let b = ArrayOperation.Insert(2, 3)
let tr = ArrayOperation.transform(a, b, {inplace: true})
t.ok(a.getOffset() === tr[0].getOffset() && b.getOffset() === tr[1].getOffset(), "Transformation should be done inplace.")
t.end()
})
test("With option 'no-conflict' conflicting operations can not be transformed.", (t) => {
let a = ArrayOperation.Insert(2, 2)
let b = ArrayOperation.Insert(2, 2)
t.throws(function() {
ArrayOperation.transform(a, b, { "no-conflict": true })
}, 'Transforming conflicting ops should throw when option "no-conflict" is enabled.')
t.end()
})
test("Conflicts: inserting at the same position", (t) => {
// this is considered a conflict as a decision is needed to determine which element comes first.
let a = ArrayOperation.Insert(2, 'a')
let b = ArrayOperation.Insert(2, 'b')
t.ok(a.hasConflict(b) && b.hasConflict(a), "Inserts at the same position are considered a conflict.")
t.end()
})
test("Conflicts: inserting at different positions", (t) => {
let a = ArrayOperation.Insert(2, 'a')
let b = ArrayOperation.Insert(4, 'b')
t.ok(!a.hasConflict(b) && !b.hasConflict(a), "Inserts at different positions should be fine.")
t.end()
})
test("Conflicts: deleting at the same position", (t) => {
// this is *not* considered a conflict as it is clear how the result should look like.
let a = ArrayOperation.Delete(2, 'a')
let b = ArrayOperation.Delete(2, 'a')
t.ok(!a.hasConflict(b) && !b.hasConflict(a), "Deletes at the same position are not a conflict.")
t.end()
})
test("Conflicts: inserting and deleting at the same position", (t) => {
// this is *not* considered a conflict as it is clear how the result should look like.
let a = ArrayOperation.Insert(2, 'a')
let b = ArrayOperation.Delete(2, 'b')
t.ok(!a.hasConflict(b) && !b.hasConflict(a), "Inserting and deleting at the same position is not a conflict.")
t.end()
})
test("Conflicts: when NOP involved", (t) => {
let a = ArrayOperation.Insert(2, 2)
let b = new ArrayOperation({type: ArrayOperation.NOP})
t.ok(!a.hasConflict(b) && !b.hasConflict(a), "NOPs should never conflict.")
t.end()
})