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 system. It is developed to power our online editing platform [Substance](http://substance.io).
291 lines (261 loc) • 10.8 kB
JavaScript
import { test } from 'substance-test'
import { ArrayOperation } from 'substance'
const NOP = ArrayOperation.NOP
function checkArrayOperationTransform (t, a, b, input, expected) {
const ops = ArrayOperation.transform(a.clone(), b.clone())
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('ArrayOperation: Insert element', (t) => {
const arr = [1, 2, 4]
const expected = [1, 2, 3, 4]
const op = ArrayOperation.Insert(2, 3)
op.apply(arr)
t.deepEqual(arr, expected, 'Should insert element.')
t.end()
})
test('ArrayOperation: Insert element after last position', (t) => {
const arr = [1, 2, 3]
const expected = [1, 2, 3, 4]
const op = ArrayOperation.Insert(arr.length, 4)
op.apply(arr)
t.deepEqual(arr, expected, 'Should append element.')
t.end()
})
test('ArrayOperation: Delete element', (t) => {
const arr = [1, 2, 3]
const expected = [1, 3]
const op = ArrayOperation.Delete(1, 2)
op.apply(arr)
t.deepEqual(arr, expected, 'Should delete element.')
t.end()
})
test('ArrayOperation: Create operation with invalid data', (t) => {
t.throws(function () {
new ArrayOperation() // eslint-disable-line no-new
}, 'Should throw if no data given.')
t.throws(function () {
new ArrayOperation({ type: 'foo' }) // eslint-disable-line no-new
}, 'Should throw for invalid type.')
t.throws(function () {
new ArrayOperation({ type: ArrayOperation.INSERT, value: 1 }) // eslint-disable-line no-new
}, 'Should throw for missing position.')
t.throws(function () {
new ArrayOperation({ type: ArrayOperation.INSERT, pos: -1, value: 1 }) // eslint-disable-line no-new
}, 'Should throw for position < 0.')
t.end()
})
test('ArrayOperation: Operation can be NOP', (t) => {
const op = ArrayOperation.Nop()
t.ok(op.isNOP(), 'Operation should be NOP')
t.end()
})
test('ArrayOperation: Apply operation on too short array.', (t) => {
const 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('ArrayOperation: Apply delete operation on wrong array.', (t) => {
const arr = [1, 2, 3]
const op = ArrayOperation.Delete(2, 4)
t.throws(function () {
op.apply(arr)
}, 'Should throw if applying delete operation with wrong value')
t.end()
})
test('ArrayOperation: 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 = ArrayOperation.Nop()
out = op.toJSON()
t.deepEqual(out, { type: 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('ArrayOperation: Transformation: a=Insert, b=Insert, a < b and b < a', (t) => {
const input = [1, 3, 5]
const expected = [1, 2, 3, 4, 5]
const a = ArrayOperation.Insert(1, 2)
const 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('ArrayOperation: Transformation: a=Insert, b=Insert, a == b', (t) => {
const input = [1, 4]
const expected = [1, 2, 3, 4]
const expected2 = [1, 3, 2, 4]
const a = ArrayOperation.Insert(1, 2)
const b = ArrayOperation.Insert(1, 3)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected2)
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('ArrayOperation: Transformation: a=Delete, b=Delete (1,2), a < b and b < a', (t) => {
const input = [1, 2, 3, 4, 5]
const expected = [1, 3, 5]
const a = ArrayOperation.Delete(1, 2)
const b = ArrayOperation.Delete(3, 4)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
test('ArrayOperation: Transformation: a=Delete, b=Delete (3), a == b', (t) => {
const input = [1, 2, 3]
const expected = [1, 3]
const a = ArrayOperation.Delete(1, 2)
const 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('ArrayOperation: Transformation: a=Insert, b=Delete (1), a < b', (t) => {
const input = [1, 3, 4, 5]
const expected = [1, 2, 3, 5]
const a = ArrayOperation.Insert(1, 2)
const 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('ArrayOperation: Transformation: a=Insert, b=Delete (2), b < a', (t) => {
const input = [1, 2, 3, 5]
const expected = [1, 3, 4, 5]
const a = ArrayOperation.Insert(3, 4)
const 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('ArrayOperation: Transformation: a=Insert, b=Delete (3), a == b', (t) => {
const input = [1, 2, 3]
const expected = [1, 4, 3]
const a = ArrayOperation.Insert(1, 4)
const b = ArrayOperation.Delete(1, 2)
checkArrayOperationTransform(t, a, b, input, expected)
checkArrayOperationTransform(t, b, a, input, expected)
t.end()
})
test('ArrayOperation: Transformation: a=NOP || b=NOP', (t) => {
const a = ArrayOperation.Insert(1, 4)
const b = 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('ArrayOperation: 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 = ArrayOperation.Nop()
inverse = op.invert()
t.ok(inverse.isNOP(), 'Inverse of a nop is a nop.')
t.end()
})
test('ArrayOperation: Transformations can be done inplace (optimzation for internal use)', (t) => {
const a = ArrayOperation.Insert(2, 3)
const b = ArrayOperation.Insert(2, 3)
const 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("ArrayOperation: With option 'no-conflict' conflicting operations can not be transformed.", (t) => {
const a = ArrayOperation.Insert(2, 2)
const 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('ArrayOperation: Conflicts: inserting at the same position', (t) => {
// this is considered a conflict as a decision is needed to determine which element comes first.
const a = ArrayOperation.Insert(2, 'a')
const 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('ArrayOperation: Conflicts: inserting at different positions', (t) => {
const a = ArrayOperation.Insert(2, 'a')
const b = ArrayOperation.Insert(4, 'b')
t.ok(!a.hasConflict(b) && !b.hasConflict(a), 'Inserts at different positions should be fine.')
t.end()
})
test('ArrayOperation: Conflicts: deleting at the same position', (t) => {
// this is *not* considered a conflict as it is clear how the result should look like.
const a = ArrayOperation.Delete(2, 'a')
const 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('ArrayOperation: 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.
const a = ArrayOperation.Insert(2, 'a')
const 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('ArrayOperation: Conflicts: when NOP involved', (t) => {
const a = ArrayOperation.Insert(2, 2)
const b = ArrayOperation.Nop()
t.ok(!a.hasConflict(b) && !b.hasConflict(a), 'NOPs should never conflict.')
t.end()
})