automerge/javascript/test/legacy_tests.ts
Alex Good 8e131922e7
Move wrappers/javascript -> javascript
Continuing our theme of treating all languages equally, move
wrappers/javascript to javascrpit. Automerge libraries for new languages
should be built at this top level if possible.
2022-10-16 19:55:54 +01:00

1387 lines
62 KiB
TypeScript

import * as assert from 'assert'
import * as Automerge from '../src'
import { assertEqualsOneOf } from './helpers'
import { decodeChange } from './legacy/columnar'
const UUID_PATTERN = /^[0-9a-f]{32}$/
const OPID_PATTERN = /^[0-9]+@[0-9a-f]{32}$/
// CORE FEATURES
//
// TODO - Cursors
// TODO - Tables
// TODO - on-pass load() & reconstruct change from opset
// TODO - micro-patches (needed for fully hydrated object in js)
// TODO - valueAt(heads) / GC
//
// AUTOMERGE UNSUPPORTED
//
// TODO - patchCallback
describe('Automerge', () => {
describe('initialization ', () => {
it('should initially be an empty map', () => {
const doc = Automerge.init()
assert.deepStrictEqual(doc, {})
})
it('should allow instantiating from an existing object', () => {
const initialState = { birds: { wrens: 3, magpies: 4 } }
const doc = Automerge.from(initialState)
assert.deepStrictEqual(doc, initialState)
})
it('should allow merging of an object initialized with `from`', () => {
let doc1 = Automerge.from({ cards: [] })
let doc2 = Automerge.merge(Automerge.init(), doc1)
assert.deepStrictEqual(doc2, { cards: [] })
})
it('should allow passing an actorId when instantiating from an existing object', () => {
const actorId = '1234'
let doc = Automerge.from({ foo: 1 }, actorId)
assert.strictEqual(Automerge.getActorId(doc), '1234')
})
it('accepts an empty object as initial state', () => {
const doc = Automerge.from({})
assert.deepStrictEqual(doc, {})
})
it('accepts an array as initial state, but converts it to an object', () => {
const doc = Automerge.from(['a', 'b', 'c'])
assert.deepStrictEqual(doc, { '0': 'a', '1': 'b', '2': 'c' })
})
it('accepts strings as initial values, but treats them as an array of characters', () => {
const doc = Automerge.from('abc')
assert.deepStrictEqual(doc, { '0': 'a', '1': 'b', '2': 'c' })
})
it('ignores numbers provided as initial values', () => {
const doc = Automerge.from(123)
assert.deepStrictEqual(doc, {})
})
it('ignores booleans provided as initial values', () => {
const doc1 = Automerge.from(false)
assert.deepStrictEqual(doc1, {})
const doc2 = Automerge.from(true)
assert.deepStrictEqual(doc2, {})
})
})
describe('sequential use', () => {
let s1, s2
beforeEach(() => {
s1 = Automerge.init()
})
it('should not mutate objects', () => {
s2 = Automerge.change(s1, doc => doc.foo = 'bar')
assert.strictEqual(s1.foo, undefined)
assert.strictEqual(s2.foo, 'bar')
})
it('changes should be retrievable', () => {
const change1 = Automerge.getLastLocalChange(s1)
s2 = Automerge.change(s1, doc => doc.foo = 'bar')
const change2 = Automerge.getLastLocalChange(s2)
assert.strictEqual(change1, undefined)
const change = decodeChange(change2)
assert.deepStrictEqual(change, {
actor: change.actor, deps: [], seq: 1, startOp: 1,
hash: change.hash, message: '', time: change.time,
ops: [{obj: '_root', key: 'foo', action: 'set', insert: false, value: 'bar', pred: []}]
})
})
it('should not register any conflicts on repeated assignment', () => {
assert.strictEqual(Automerge.getConflicts(s1, 'foo'), undefined)
s1 = Automerge.change(s1, 'change', doc => doc.foo = 'one')
assert.strictEqual(Automerge.getConflicts(s1, 'foo'), undefined)
s1 = Automerge.change(s1, 'change', doc => doc.foo = 'two')
assert.strictEqual(Automerge.getConflicts(s1, 'foo'), undefined)
})
describe('changes', () => {
it('should group several changes', () => {
s2 = Automerge.change(s1, 'change message', doc => {
doc.first = 'one'
assert.strictEqual(doc.first, 'one')
doc.second = 'two'
assert.deepStrictEqual(doc, {
first: 'one', second: 'two'
})
})
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, {first: 'one', second: 'two'})
})
it('should freeze objects if desired', () => {
s1 = Automerge.init({freeze: true})
s2 = Automerge.change(s1, doc => doc.foo = 'bar')
try {
s2.foo = 'lemon'
} catch (e) { }
assert.strictEqual(s2.foo, 'bar')
let deleted = false
try {
deleted = delete s2.foo
} catch (e) { }
assert.strictEqual(s2.foo, 'bar')
assert.strictEqual(deleted, false)
Automerge.change(s2, () => {
try {
s2.foo = 'lemon'
} catch (e) { }
assert.strictEqual(s2.foo, 'bar')
})
assert.throws(() => { Object.assign(s2, {x: 4}) })
assert.strictEqual(s2.x, undefined)
})
it('should allow repeated reading and writing of values', () => {
s2 = Automerge.change(s1, 'change message', doc => {
doc.value = 'a'
assert.strictEqual(doc.value, 'a')
doc.value = 'b'
doc.value = 'c'
assert.strictEqual(doc.value, 'c')
})
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, {value: 'c'})
})
it('should not record conflicts when writing the same field several times within one change', () => {
s1 = Automerge.change(s1, 'change message', doc => {
doc.value = 'a'
doc.value = 'b'
doc.value = 'c'
})
assert.strictEqual(s1.value, 'c')
assert.strictEqual(Automerge.getConflicts(s1, 'value'), undefined)
})
it('should return the unchanged state object if nothing changed', () => {
s2 = Automerge.change(s1, () => {})
assert.strictEqual(s2, s1)
})
it('should ignore field updates that write the existing value', () => {
s1 = Automerge.change(s1, doc => doc.field = 123)
s2 = Automerge.change(s1, doc => doc.field = 123)
assert.strictEqual(s2, s1)
})
it('should not ignore field updates that resolve a conflict', () => {
s2 = Automerge.merge(Automerge.init(), s1)
s1 = Automerge.change(s1, doc => doc.field = 123)
s2 = Automerge.change(s2, doc => doc.field = 321)
s1 = Automerge.merge(s1, s2)
assert.strictEqual(Object.keys(Automerge.getConflicts(s1, 'field')).length, 2)
const resolved = Automerge.change(s1, doc => doc.field = s1.field)
assert.notStrictEqual(resolved, s1)
assert.deepStrictEqual(resolved, {field: s1.field})
assert.strictEqual(Automerge.getConflicts(resolved, 'field'), undefined)
})
it('should ignore list element updates that write the existing value', () => {
s1 = Automerge.change(s1, doc => doc.list = [123])
s2 = Automerge.change(s1, doc => doc.list[0] = 123)
assert.strictEqual(s2, s1)
})
it('should not ignore list element updates that resolve a conflict', () => {
s1 = Automerge.change(s1, doc => doc.list = [1])
s2 = Automerge.merge(Automerge.init(), s1)
s1 = Automerge.change(s1, doc => doc.list[0] = 123)
s2 = Automerge.change(s2, doc => doc.list[0] = 321)
s1 = Automerge.merge(s1, s2)
assert.deepStrictEqual(Automerge.getConflicts(s1.list, 0), {
[`3@${Automerge.getActorId(s1)}`]: 123,
[`3@${Automerge.getActorId(s2)}`]: 321
})
const resolved = Automerge.change(s1, doc => doc.list[0] = s1.list[0])
assert.deepStrictEqual(resolved, s1)
assert.notStrictEqual(resolved, s1)
assert.strictEqual(Automerge.getConflicts(resolved.list, 0), undefined)
})
it('should sanity-check arguments', () => {
s1 = Automerge.change(s1, doc => doc.nested = {})
assert.throws(() => { Automerge.change({}, doc => doc.foo = 'bar') }, /must be the document root/)
assert.throws(() => { Automerge.change(s1.nested, doc => doc.foo = 'bar') }, /must be the document root/)
})
it('should not allow nested change blocks', () => {
assert.throws(() => {
Automerge.change(s1, doc1 => {
Automerge.change(doc1, doc2 => {
doc2.foo = 'bar'
})
})
}, /Calls to Automerge.change cannot be nested/)
assert.throws(() => {
s1 = Automerge.change(s1, doc1 => {
s2 = Automerge.change(s1, doc2 => doc2.two = 2)
doc1.one = 1
})
}, /Attempting to use an outdated Automerge document/)
})
it('should not allow the same base document to be used for multiple changes', () => {
assert.throws(() => {
Automerge.change(s1, doc => doc.one = 1)
Automerge.change(s1, doc => doc.two = 2)
}, /Attempting to use an outdated Automerge document/)
})
it('should allow a document to be cloned', () => {
s1 = Automerge.change(s1, doc => doc.zero = 0)
s2 = Automerge.clone(s1)
s1 = Automerge.change(s1, doc => doc.one = 1)
s2 = Automerge.change(s2, doc => doc.two = 2)
assert.deepStrictEqual(s1, {zero: 0, one: 1})
assert.deepStrictEqual(s2, {zero: 0, two: 2})
Automerge.free(s1)
Automerge.free(s2)
})
it('should work with Object.assign merges', () => {
s1 = Automerge.change(s1, doc1 => {
doc1.stuff = {foo: 'bar', baz: 'blur'}
})
s1 = Automerge.change(s1, doc1 => {
doc1.stuff = Object.assign({}, doc1.stuff, {baz: 'updated!'})
})
assert.deepStrictEqual(s1, {stuff: {foo: 'bar', baz: 'updated!'}})
})
it('should support Date objects in maps', () => {
const now = new Date()
s1 = Automerge.change(s1, doc => doc.now = now)
let changes = Automerge.getAllChanges(s1)
;[s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.now instanceof Date, true)
assert.strictEqual(s2.now.getTime(), now.getTime())
})
it('should support Date objects in lists', () => {
const now = new Date()
s1 = Automerge.change(s1, doc => doc.list = [now])
let changes = Automerge.getAllChanges(s1)
;[s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.list[0] instanceof Date, true)
assert.strictEqual(s2.list[0].getTime(), now.getTime())
})
it('should call patchCallback if supplied', () => {
const callbacks = [], actor = Automerge.getActorId(s1)
const s2 = Automerge.change(s1, {
patchCallback: (patch, before, after) => callbacks.push({patch, before, after})
}, doc => {
doc.birds = ['Goldfinch']
})
assert.strictEqual(callbacks.length, 2)
assert.deepStrictEqual(callbacks[0].patch, { action: "put", path: ["birds"], value: [], conflict: false})
assert.deepStrictEqual(callbacks[1].patch, { action: "splice", path: ["birds",0], values: ["Goldfinch"] })
assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[1].after, s2)
})
it('should call a patchCallback set up on document initialisation', () => {
const callbacks = []
s1 = Automerge.init({
patchCallback: (patch, before, after) => callbacks.push({patch, before, after })
})
const s2 = Automerge.change(s1, doc => doc.bird = 'Goldfinch')
const actor = Automerge.getActorId(s1)
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, {
action: "put", path: ["bird"], value: "Goldfinch", conflict: false
})
assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[0].after, s2)
})
})
describe('emptyChange()', () => {
it('should append an empty change to the history', () => {
s1 = Automerge.change(s1, 'first change', doc => doc.field = 123)
s2 = Automerge.emptyChange(s1, 'empty change')
assert.notStrictEqual(s2, s1)
assert.deepStrictEqual(s2, s1)
assert.deepStrictEqual(Automerge.getHistory(s2).map(state => state.change.message), ['first change', 'empty change'])
})
it('should reference dependencies', () => {
s1 = Automerge.change(s1, doc => doc.field = 123)
s2 = Automerge.merge(Automerge.init(), s1)
s2 = Automerge.change(s2, doc => doc.other = 'hello')
s1 = Automerge.emptyChange(Automerge.merge(s1, s2))
const history = Automerge.getHistory(s1)
const emptyChange = history[2].change
assert.deepStrictEqual(emptyChange.deps, [history[0].change.hash, history[1].change.hash].sort())
assert.deepStrictEqual(emptyChange.ops, [])
})
})
describe('root object', () => {
it('should handle single-property assignment', () => {
s1 = Automerge.change(s1, 'set bar', doc => doc.foo = 'bar')
s1 = Automerge.change(s1, 'set zap', doc => doc.zip = 'zap')
assert.strictEqual(s1.foo, 'bar')
assert.strictEqual(s1.zip, 'zap')
assert.deepStrictEqual(s1, {foo: 'bar', zip: 'zap'})
})
it('should allow floating-point values', () => {
s1 = Automerge.change(s1, doc => doc.number = 1589032171.1)
assert.strictEqual(s1.number, 1589032171.1)
})
it('should handle multi-property assignment', () => {
s1 = Automerge.change(s1, 'multi-assign', doc => {
Object.assign(doc, {foo: 'bar', answer: 42})
})
assert.strictEqual(s1.foo, 'bar')
assert.strictEqual(s1.answer, 42)
assert.deepStrictEqual(s1, {foo: 'bar', answer: 42})
})
it('should handle root property deletion', () => {
s1 = Automerge.change(s1, 'set foo', doc => { doc.foo = 'bar'; doc.something = null })
s1 = Automerge.change(s1, 'del foo', doc => { delete doc.foo })
assert.strictEqual(s1.foo, undefined)
assert.strictEqual(s1.something, null)
assert.deepStrictEqual(s1, {something: null})
})
it('should follow JS delete behavior', () => {
s1 = Automerge.change(s1, 'set foo', doc => { doc.foo = 'bar' })
let deleted
s1 = Automerge.change(s1, 'del foo', doc => {
deleted = delete doc.foo
})
assert.strictEqual(deleted, true)
let deleted2
assert.doesNotThrow(() => {
s1 = Automerge.change(s1, 'del baz', doc => {
deleted2 = delete doc.baz
})
})
assert.strictEqual(deleted2, true)
})
it('should allow the type of a property to be changed', () => {
s1 = Automerge.change(s1, 'set number', doc => doc.prop = 123)
assert.strictEqual(s1.prop, 123)
s1 = Automerge.change(s1, 'set string', doc => doc.prop = '123')
assert.strictEqual(s1.prop, '123')
s1 = Automerge.change(s1, 'set null', doc => doc.prop = null)
assert.strictEqual(s1.prop, null)
s1 = Automerge.change(s1, 'set bool', doc => doc.prop = true)
assert.strictEqual(s1.prop, true)
})
it('should require property names to be valid', () => {
assert.throws(() => {
Automerge.change(s1, 'foo', doc => doc[''] = 'x')
}, /must not be an empty string/)
})
it('should not allow assignment of unsupported datatypes', () => {
Automerge.change(s1, doc => {
assert.throws(() => { doc.foo = undefined }, /Unsupported type of value: undefined/)
assert.throws(() => { doc.foo = {prop: undefined} }, /Unsupported type of value: undefined/)
assert.throws(() => { doc.foo = () => {} }, /Unsupported type of value: function/)
assert.throws(() => { doc.foo = Symbol('foo') }, /Unsupported type of value: symbol/)
})
})
})
describe('nested maps', () => {
it('should assign an objectId to nested maps', () => {
s1 = Automerge.change(s1, doc => { doc.nested = {} })
let id = Automerge.getObjectId(s1.nested)
assert.strictEqual(OPID_PATTERN.test(Automerge.getObjectId(s1.nested)), true)
assert.notEqual(Automerge.getObjectId(s1.nested), '_root')
})
it('should handle assignment of a nested property', () => {
s1 = Automerge.change(s1, 'first change', doc => {
doc.nested = {}
doc.nested.foo = 'bar'
})
s1 = Automerge.change(s1, 'second change', doc => {
doc.nested.one = 1
})
assert.deepStrictEqual(s1, {nested: {foo: 'bar', one: 1}})
assert.deepStrictEqual(s1.nested, {foo: 'bar', one: 1})
assert.strictEqual(s1.nested.foo, 'bar')
assert.strictEqual(s1.nested.one, 1)
})
it('should handle assignment of an object literal', () => {
s1 = Automerge.change(s1, doc => {
doc.textStyle = {bold: false, fontSize: 12}
})
assert.deepStrictEqual(s1, {textStyle: {bold: false, fontSize: 12}})
assert.deepStrictEqual(s1.textStyle, {bold: false, fontSize: 12})
assert.strictEqual(s1.textStyle.bold, false)
assert.strictEqual(s1.textStyle.fontSize, 12)
})
it('should handle assignment of multiple nested properties', () => {
s1 = Automerge.change(s1, doc => {
doc.textStyle = {bold: false, fontSize: 12}
Object.assign(doc.textStyle, {typeface: 'Optima', fontSize: 14})
})
assert.strictEqual(s1.textStyle.typeface, 'Optima')
assert.strictEqual(s1.textStyle.bold, false)
assert.strictEqual(s1.textStyle.fontSize, 14)
assert.deepStrictEqual(s1.textStyle, {typeface: 'Optima', bold: false, fontSize: 14})
})
it('should handle arbitrary-depth nesting', () => {
s1 = Automerge.change(s1, doc => {
doc.a = {b: {c: {d: {e: {f: {g: 'h'}}}}}}
})
s1 = Automerge.change(s1, doc => {
doc.a.b.c.d.e.f.i = 'j'
})
assert.deepStrictEqual(s1, {a: { b: { c: { d: { e: { f: { g: 'h', i: 'j'}}}}}}})
assert.strictEqual(s1.a.b.c.d.e.f.g, 'h')
assert.strictEqual(s1.a.b.c.d.e.f.i, 'j')
})
it('should allow an old object to be replaced with a new one', () => {
s1 = Automerge.change(s1, 'change 1', doc => {
doc.myPet = {species: 'dog', legs: 4, breed: 'dachshund'}
})
s2 = Automerge.change(s1, 'change 2', doc => {
doc.myPet = {species: 'koi', variety: '紅白', colors: {red: true, white: true, black: false}}
})
assert.deepStrictEqual(s1.myPet, {
species: 'dog', legs: 4, breed: 'dachshund'
})
assert.strictEqual(s1.myPet.breed, 'dachshund')
assert.deepStrictEqual(s2.myPet, {
species: 'koi', variety: '紅白',
colors: {red: true, white: true, black: false}
})
assert.strictEqual(s2.myPet.breed, undefined)
assert.strictEqual(s2.myPet.variety, '紅白')
})
it('should allow fields to be changed between primitive and nested map', () => {
s1 = Automerge.change(s1, doc => doc.color = '#ff7f00')
assert.strictEqual(s1.color, '#ff7f00')
s1 = Automerge.change(s1, doc => doc.color = {red: 255, green: 127, blue: 0})
assert.deepStrictEqual(s1.color, {red: 255, green: 127, blue: 0})
s1 = Automerge.change(s1, doc => doc.color = '#ff7f00')
assert.strictEqual(s1.color, '#ff7f00')
})
it('should not allow several references to the same map object', () => {
s1 = Automerge.change(s1, doc => doc.object = {})
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = doc.object })
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = s1.object })
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = {}; doc.y = doc.x })
}, /Cannot create a reference to an existing document object/)
})
it('should not allow object-copying idioms', () => {
s1 = Automerge.change(s1, doc => {
doc.items = [{id: 'id1', name: 'one'}, {id: 'id2', name: 'two'}]
})
// People who have previously worked with immutable state in JavaScript may be tempted
// to use idioms like this, which don't work well with Automerge -- see e.g.
// https://github.com/automerge/automerge/issues/260
assert.throws(() => {
Automerge.change(s1, doc => {
doc.items = [...doc.items, {id: 'id3', name: 'three'}]
})
}, /Cannot create a reference to an existing document object/)
})
it('should handle deletion of properties within a map', () => {
s1 = Automerge.change(s1, 'set style', doc => {
doc.textStyle = {typeface: 'Optima', bold: false, fontSize: 12}
})
s1 = Automerge.change(s1, 'non-bold', doc => delete doc.textStyle.bold)
assert.strictEqual(s1.textStyle.bold, undefined)
assert.deepStrictEqual(s1.textStyle, {typeface: 'Optima', fontSize: 12})
})
it('should handle deletion of references to a map', () => {
s1 = Automerge.change(s1, 'make rich text doc', doc => {
Object.assign(doc, {title: 'Hello', textStyle: {typeface: 'Optima', fontSize: 12}})
})
s1 = Automerge.change(s1, doc => delete doc.textStyle)
assert.strictEqual(s1.textStyle, undefined)
assert.deepStrictEqual(s1, {title: 'Hello'})
})
it('should validate field names', () => {
s1 = Automerge.change(s1, doc => doc.nested = {})
assert.throws(() => { Automerge.change(s1, doc => doc.nested[''] = 'x') }, /must not be an empty string/)
assert.throws(() => { Automerge.change(s1, doc => doc.nested = {'': 'x'}) }, /must not be an empty string/)
})
})
describe('lists', () => {
it('should allow elements to be inserted', () => {
s1 = Automerge.change(s1, doc => doc.noodles = [])
s1 = Automerge.change(s1, doc => doc.noodles.insertAt(0, 'udon', 'soba'))
s1 = Automerge.change(s1, doc => doc.noodles.insertAt(1, 'ramen'))
assert.deepStrictEqual(s1, {noodles: ['udon', 'ramen', 'soba']})
assert.deepStrictEqual(s1.noodles, ['udon', 'ramen', 'soba'])
assert.strictEqual(s1.noodles[0], 'udon')
assert.strictEqual(s1.noodles[1], 'ramen')
assert.strictEqual(s1.noodles[2], 'soba')
assert.strictEqual(s1.noodles.length, 3)
})
it('should handle assignment of a list literal', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'ramen', 'soba'])
assert.deepStrictEqual(s1, {noodles: ['udon', 'ramen', 'soba']})
assert.deepStrictEqual(s1.noodles, ['udon', 'ramen', 'soba'])
assert.strictEqual(s1.noodles[0], 'udon')
assert.strictEqual(s1.noodles[1], 'ramen')
assert.strictEqual(s1.noodles[2], 'soba')
assert.strictEqual(s1.noodles[3], undefined)
assert.strictEqual(s1.noodles.length, 3)
})
it('should only allow numeric indexes', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'ramen', 'soba'])
s1 = Automerge.change(s1, doc => doc.noodles[1] = 'Ramen!')
assert.strictEqual(s1.noodles[1], 'Ramen!')
s1 = Automerge.change(s1, doc => doc.noodles['1'] = 'RAMEN!!!')
assert.strictEqual(s1.noodles[1], 'RAMEN!!!')
assert.throws(() => { Automerge.change(s1, doc => doc.noodles.favourite = 'udon') }, /list index must be a number/)
assert.throws(() => { Automerge.change(s1, doc => doc.noodles[''] = 'udon') }, /list index must be a number/)
assert.throws(() => { Automerge.change(s1, doc => doc.noodles['1e6'] = 'udon') }, /list index must be a number/)
})
it('should handle deletion of list elements', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'ramen', 'soba'])
s1 = Automerge.change(s1, doc => delete doc.noodles[1])
assert.deepStrictEqual(s1.noodles, ['udon', 'soba'])
s1 = Automerge.change(s1, doc => doc.noodles.deleteAt(1))
assert.deepStrictEqual(s1.noodles, ['udon'])
assert.strictEqual(s1.noodles[0], 'udon')
assert.strictEqual(s1.noodles[1], undefined)
assert.strictEqual(s1.noodles[2], undefined)
assert.strictEqual(s1.noodles.length, 1)
})
it('should handle assignment of individual list indexes', () => {
s1 = Automerge.change(s1, doc => doc.japaneseFood = ['udon', 'ramen', 'soba'])
s1 = Automerge.change(s1, doc => doc.japaneseFood[1] = 'sushi')
assert.deepStrictEqual(s1.japaneseFood, ['udon', 'sushi', 'soba'])
assert.strictEqual(s1.japaneseFood[0], 'udon')
assert.strictEqual(s1.japaneseFood[1], 'sushi')
assert.strictEqual(s1.japaneseFood[2], 'soba')
assert.strictEqual(s1.japaneseFood[3], undefined)
assert.strictEqual(s1.japaneseFood.length, 3)
})
it('concurrent edits insert in reverse actorid order if counters equal', () => {
s1 = Automerge.init('aaaa')
s2 = Automerge.init('bbbb')
s1 = Automerge.change(s1, doc => doc.list = [])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(0, 0, "2@aaaa"))
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "2@bbbb"))
s2 = Automerge.merge(s2, s1)
assert.deepStrictEqual(Automerge.toJS(s2).list, ["2@bbbb", "2@aaaa"])
})
it('concurrent edits insert in reverse counter order if different', () => {
s1 = Automerge.init('aaaa')
s2 = Automerge.init('bbbb')
s1 = Automerge.change(s1, doc => doc.list = [])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(0, 0, "2@aaaa"))
s2 = Automerge.change(s2, doc => doc.foo = "2@bbbb")
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "3@bbbb"))
s2 = Automerge.merge(s2, s1)
assert.deepStrictEqual(s2.list, ["3@bbbb", "2@aaaa"])
})
it('should treat out-by-one assignment as insertion', () => {
s1 = Automerge.change(s1, doc => doc.japaneseFood = ['udon'])
s1 = Automerge.change(s1, doc => doc.japaneseFood[1] = 'sushi')
assert.deepStrictEqual(s1.japaneseFood, ['udon', 'sushi'])
assert.strictEqual(s1.japaneseFood[0], 'udon')
assert.strictEqual(s1.japaneseFood[1], 'sushi')
assert.strictEqual(s1.japaneseFood[2], undefined)
assert.strictEqual(s1.japaneseFood.length, 2)
})
it('should not allow out-of-range assignment', () => {
s1 = Automerge.change(s1, doc => doc.japaneseFood = ['udon'])
assert.throws(() => { Automerge.change(s1, doc => doc.japaneseFood[4] = 'ramen') }, /is out of bounds/)
})
it('should allow bulk assignment of multiple list indexes', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'ramen', 'soba'])
s1 = Automerge.change(s1, doc => Object.assign(doc.noodles, {0: 'うどん', 2: 'そば'}))
assert.deepStrictEqual(s1.noodles, ['うどん', 'ramen', 'そば'])
assert.strictEqual(s1.noodles[0], 'うどん')
assert.strictEqual(s1.noodles[1], 'ramen')
assert.strictEqual(s1.noodles[2], 'そば')
assert.strictEqual(s1.noodles.length, 3)
})
it('should handle nested objects', () => {
s1 = Automerge.change(s1, doc => doc.noodles = [{type: 'ramen', dishes: ['tonkotsu', 'shoyu']}])
s1 = Automerge.change(s1, doc => doc.noodles.push({type: 'udon', dishes: ['tempura udon']}))
s1 = Automerge.change(s1, doc => doc.noodles[0].dishes.push('miso'))
assert.deepStrictEqual(s1, {noodles: [
{type: 'ramen', dishes: ['tonkotsu', 'shoyu', 'miso']},
{type: 'udon', dishes: ['tempura udon']}
]})
assert.deepStrictEqual(s1.noodles[0], {
type: 'ramen', dishes: ['tonkotsu', 'shoyu', 'miso']
})
assert.deepStrictEqual(s1.noodles[1], {
type: 'udon', dishes: ['tempura udon']
})
})
it('should handle nested lists', () => {
s1 = Automerge.change(s1, doc => doc.noodleMatrix = [['ramen', 'tonkotsu', 'shoyu']])
s1 = Automerge.change(s1, doc => doc.noodleMatrix.push(['udon', 'tempura udon']))
s1 = Automerge.change(s1, doc => doc.noodleMatrix[0].push('miso'))
assert.deepStrictEqual(s1.noodleMatrix, [['ramen', 'tonkotsu', 'shoyu', 'miso'], ['udon', 'tempura udon']])
assert.deepStrictEqual(s1.noodleMatrix[0], ['ramen', 'tonkotsu', 'shoyu', 'miso'])
assert.deepStrictEqual(s1.noodleMatrix[1], ['udon', 'tempura udon'])
})
it('should handle deep nesting', () => {
s1 = Automerge.change(s1, doc => doc.nesting = {
maps: { m1: { m2: { foo: "bar", baz: {} }, m2a: { } } },
lists: [ [ 1, 2, 3 ], [ [ 3, 4, 5, [6]], 7 ] ],
mapsinlists: [ { foo: "bar" }, [ { bar: "baz" } ] ],
listsinmaps: { foo: [1, 2, 3], bar: [ [ { baz: "123" } ] ] }
})
s1 = Automerge.change(s1, doc => {
doc.nesting.maps.m1a = "123"
doc.nesting.maps.m1.m2.baz.xxx = "123"
delete doc.nesting.maps.m1.m2a
doc.nesting.lists.shift()
doc.nesting.lists[0][0].pop()
doc.nesting.lists[0][0].push(100)
doc.nesting.mapsinlists[0].foo = "baz"
doc.nesting.mapsinlists[1][0].foo = "bar"
delete doc.nesting.mapsinlists[1]
doc.nesting.listsinmaps.foo.push(4)
doc.nesting.listsinmaps.bar[0][0].baz = "456"
delete doc.nesting.listsinmaps.bar
})
assert.deepStrictEqual(s1, { nesting: {
maps: { m1: { m2: { foo: "bar", baz: { xxx: "123" } } }, m1a: "123" },
lists: [ [ [ 3, 4, 5, 100 ], 7 ] ],
mapsinlists: [ { foo: "baz" } ],
listsinmaps: { foo: [1, 2, 3, 4] }
}})
})
it('should handle replacement of the entire list', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'soba', 'ramen'])
s1 = Automerge.change(s1, doc => doc.japaneseNoodles = doc.noodles.slice())
s1 = Automerge.change(s1, doc => doc.noodles = ['wonton', 'pho'])
assert.deepStrictEqual(s1, {
noodles: ['wonton', 'pho'],
japaneseNoodles: ['udon', 'soba', 'ramen']
})
assert.deepStrictEqual(s1.noodles, ['wonton', 'pho'])
assert.strictEqual(s1.noodles[0], 'wonton')
assert.strictEqual(s1.noodles[1], 'pho')
assert.strictEqual(s1.noodles[2], undefined)
assert.strictEqual(s1.noodles.length, 2)
})
it('should allow assignment to change the type of a list element', () => {
s1 = Automerge.change(s1, doc => doc.noodles = ['udon', 'soba', 'ramen'])
assert.deepStrictEqual(s1.noodles, ['udon', 'soba', 'ramen'])
s1 = Automerge.change(s1, doc => doc.noodles[1] = {type: 'soba', options: ['hot', 'cold']})
assert.deepStrictEqual(s1.noodles, ['udon', {type: 'soba', options: ['hot', 'cold']}, 'ramen'])
s1 = Automerge.change(s1, doc => doc.noodles[1] = ['hot soba', 'cold soba'])
assert.deepStrictEqual(s1.noodles, ['udon', ['hot soba', 'cold soba'], 'ramen'])
s1 = Automerge.change(s1, doc => doc.noodles[1] = 'soba is the best')
assert.deepStrictEqual(s1.noodles, ['udon', 'soba is the best', 'ramen'])
})
it('should allow list creation and assignment in the same change callback', () => {
s1 = Automerge.change(Automerge.init(), doc => {
doc.letters = ['a', 'b', 'c']
doc.letters[1] = 'd'
})
assert.strictEqual(s1.letters[1], 'd')
})
it('should allow adding and removing list elements in the same change callback', () => {
s1 = Automerge.change(Automerge.init(), doc => doc.noodles = [])
s1 = Automerge.change(s1, doc => {
doc.noodles.push('udon')
doc.noodles.deleteAt(0)
})
assert.deepStrictEqual(s1, {noodles: []})
// do the add-remove cycle twice, test for #151 (https://github.com/automerge/automerge/issues/151)
s1 = Automerge.change(s1, doc => {
doc.noodles.push('soba')
doc.noodles.deleteAt(0)
})
assert.deepStrictEqual(s1, {noodles: []})
})
it('should handle arbitrary-depth nesting', () => {
s1 = Automerge.change(s1, doc => doc.maze = [[[[[[[['noodles', ['here']]]]]]]]])
s1 = Automerge.change(s1, doc => doc.maze[0][0][0][0][0][0][0][1].unshift('found'))
assert.deepStrictEqual(s1.maze, [[[[[[[['noodles', ['found', 'here']]]]]]]]])
assert.deepStrictEqual(s1.maze[0][0][0][0][0][0][0][1][1], 'here')
s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s1,s2)
})
it('should not allow several references to the same list object', () => {
s1 = Automerge.change(s1, doc => doc.list = [])
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = doc.list })
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = s1.list })
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => { doc.x = []; doc.y = doc.x })
}, /Cannot create a reference to an existing document object/)
})
})
describe('counters', () => {
// counter
it('should allow deleting counters from maps', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.birds = {wrens: new Automerge.Counter(1)})
const s2 = Automerge.change(s1, doc => doc.birds.wrens.increment(2))
const s3 = Automerge.change(s2, doc => delete doc.birds.wrens)
assert.deepStrictEqual(s2, {birds: {wrens: new Automerge.Counter(3)}})
assert.deepStrictEqual(s3, {birds: {}})
})
// counter
/*
it('should not allow deleting counters from lists', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.recordings = [new Automerge.Counter(1)])
const s2 = Automerge.change(s1, doc => doc.recordings[0].increment(2))
assert.deepStrictEqual(s2, {recordings: [new Automerge.Counter(3)]})
assert.throws(() => { Automerge.change(s2, doc => doc.recordings.deleteAt(0)) }, /Unsupported operation/)
})
*/
})
})
describe('concurrent use', () => {
let s1, s2, s3, s4
beforeEach(() => {
s1 = Automerge.init()
s2 = Automerge.init()
s3 = Automerge.init()
s4 = Automerge.init()
})
it('should merge concurrent updates of different properties', () => {
s1 = Automerge.change(s1, doc => doc.foo = 'bar')
s2 = Automerge.change(s2, doc => doc.hello = 'world')
s3 = Automerge.merge(s1, s2)
assert.strictEqual(s3.foo, 'bar')
assert.strictEqual(s3.hello, 'world')
assert.deepStrictEqual(s3, {foo: 'bar', hello: 'world'})
assert.strictEqual(Automerge.getConflicts(s3, 'foo'), undefined)
assert.strictEqual(Automerge.getConflicts(s3, 'hello'), undefined)
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3,s4)
})
it('should add concurrent increments of the same property', () => {
s1 = Automerge.change(s1, doc => doc.counter = new Automerge.Counter())
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.counter.increment())
s2 = Automerge.change(s2, doc => doc.counter.increment(2))
s3 = Automerge.merge(s1, s2)
assert.strictEqual(s1.counter.value, 1)
assert.strictEqual(s2.counter.value, 2)
assert.strictEqual(s3.counter.value, 3)
assert.strictEqual(Automerge.getConflicts(s3, 'counter'), undefined)
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3,s4)
})
it('should add increments only to the values they precede', () => {
s1 = Automerge.change(s1, doc => doc.counter = new Automerge.Counter(0))
s1 = Automerge.change(s1, doc => doc.counter.increment())
s2 = Automerge.change(s2, doc => doc.counter = new Automerge.Counter(100))
s2 = Automerge.change(s2, doc => doc.counter.increment(3))
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3, {counter: new Automerge.Counter(1)})
} else {
assert.deepStrictEqual(s3, {counter: new Automerge.Counter(103)})
}
assert.deepStrictEqual(Automerge.getConflicts(s3, 'counter'), {
[`1@${Automerge.getActorId(s1)}`]: new Automerge.Counter(1),
[`1@${Automerge.getActorId(s2)}`]: new Automerge.Counter(103)
})
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3,s4)
})
it('should detect concurrent updates of the same field', () => {
s1 = Automerge.change(s1, doc => doc.field = 'one')
s2 = Automerge.change(s2, doc => doc.field = 'two')
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3, {field: 'one'})
} else {
assert.deepStrictEqual(s3, {field: 'two'})
}
assert.deepStrictEqual(Automerge.getConflicts(s3, 'field'), {
[`1@${Automerge.getActorId(s1)}`]: 'one',
[`1@${Automerge.getActorId(s2)}`]: 'two'
})
})
it('should detect concurrent updates of the same list element', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['finch'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds[0] = 'greenfinch')
s2 = Automerge.change(s2, doc => doc.birds[0] = 'goldfinch')
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3.birds, ['greenfinch'])
} else {
assert.deepStrictEqual(s3.birds, ['goldfinch'])
}
assert.deepStrictEqual(Automerge.getConflicts(s3.birds, 0), {
[`3@${Automerge.getActorId(s1)}`]: 'greenfinch',
[`3@${Automerge.getActorId(s2)}`]: 'goldfinch'
})
})
it.skip('should handle assignment conflicts of different types', () => {
s1 = Automerge.change(s1, doc => doc.field = 'string')
s2 = Automerge.change(s2, doc => doc.field = ['list'])
s3 = Automerge.change(s3, doc => doc.field = {thing: 'map'})
s1 = Automerge.merge(Automerge.merge(s1, s2), s3)
assertEqualsOneOf(s1.field, 'string', ['list'], {thing: 'map'})
assert.deepStrictEqual(Automerge.getConflicts(s1, 'field'), {
[`1@${Automerge.getActorId(s1)}`]: 'string',
[`1@${Automerge.getActorId(s2)}`]: ['list'],
[`1@${Automerge.getActorId(s3)}`]: {thing: 'map'}
})
})
it('should handle changes within a conflicting map field', () => {
s1 = Automerge.change(s1, doc => doc.field = 'string')
s2 = Automerge.change(s2, doc => doc.field = {})
s2 = Automerge.change(s2, doc => doc.field.innerKey = 42)
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(s3.field, 'string', {innerKey: 42})
assert.deepStrictEqual(Automerge.getConflicts(s3, 'field'), {
[`1@${Automerge.getActorId(s1)}`]: 'string',
[`1@${Automerge.getActorId(s2)}`]: {innerKey: 42}
})
})
// FIXME - difficult bug here - patches arrive for conflicted subobject
it.skip('should handle changes within a conflicting list element', () => {
s1 = Automerge.change(s1, doc => doc.list = ['hello'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list[0] = {map1: true})
s1 = Automerge.change(s1, doc => doc.list[0].key = 1)
s2 = Automerge.change(s2, doc => doc.list[0] = {map2: true})
s2 = Automerge.change(s2, doc => doc.list[0].key = 2)
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3.list, [{map1: true, key: 1}])
} else {
assert.deepStrictEqual(s3.list, [{map2: true, key: 2}])
}
assert.deepStrictEqual(Automerge.getConflicts(s3.list, 0), {
[`3@${Automerge.getActorId(s1)}`]: {map1: true, key: 1},
[`3@${Automerge.getActorId(s2)}`]: {map2: true, key: 2}
})
})
it('should not merge concurrently assigned nested maps', () => {
s1 = Automerge.change(s1, doc => doc.config = {background: 'blue'})
s2 = Automerge.change(s2, doc => doc.config = {logo_url: 'logo.png'})
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(s3.config, {background: 'blue'}, {logo_url: 'logo.png'})
assert.deepStrictEqual(Automerge.getConflicts(s3, 'config'), {
[`1@${Automerge.getActorId(s1)}`]: {background: 'blue'},
[`1@${Automerge.getActorId(s2)}`]: {logo_url: 'logo.png'}
})
})
it('should clear conflicts after assigning a new value', () => {
s1 = Automerge.change(s1, doc => doc.field = 'one')
s2 = Automerge.change(s2, doc => doc.field = 'two')
s3 = Automerge.merge(s1, s2)
s3 = Automerge.change(s3, doc => doc.field = 'three')
assert.deepStrictEqual(s3, {field: 'three'})
assert.strictEqual(Automerge.getConflicts(s3, 'field'), undefined)
s2 = Automerge.merge(s2, s3)
assert.deepStrictEqual(s2, {field: 'three'})
assert.strictEqual(Automerge.getConflicts(s2, 'field'), undefined)
})
it('should handle concurrent insertions at different list positions', () => {
s1 = Automerge.change(s1, doc => doc.list = ['one', 'three'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(1, 0, 'two'))
s2 = Automerge.change(s2, doc => doc.list.push('four'))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3, {list: ['one', 'two', 'three', 'four']})
assert.strictEqual(Automerge.getConflicts(s3, 'list'), undefined)
})
it('should handle concurrent insertions at the same list position', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['parakeet'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.push('starling'))
s2 = Automerge.change(s2, doc => doc.birds.push('chaffinch'))
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(s3.birds, ['parakeet', 'starling', 'chaffinch'], ['parakeet', 'chaffinch', 'starling'])
s2 = Automerge.merge(s2, s3)
assert.deepStrictEqual(s2, s3)
})
it('should handle concurrent assignment and deletion of a map entry', () => {
// Add-wins semantics
s1 = Automerge.change(s1, doc => doc.bestBird = 'robin')
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => delete doc.bestBird)
s2 = Automerge.change(s2, doc => doc.bestBird = 'magpie')
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, {bestBird: 'magpie'})
assert.deepStrictEqual(s3, {bestBird: 'magpie'})
assert.strictEqual(Automerge.getConflicts(s3, 'bestBird'), undefined)
})
it('should handle concurrent assignment and deletion of a list element', () => {
// Concurrent assignment ressurects a deleted list element. Perhaps a little
// surprising, but consistent with add-wins semantics of maps (see test above)
s1 = Automerge.change(s1, doc => doc.birds = ['blackbird', 'thrush', 'goldfinch'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds[1] = 'starling')
s2 = Automerge.change(s2, doc => doc.birds.splice(1, 1))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1.birds, ['blackbird', 'starling', 'goldfinch'])
assert.deepStrictEqual(s2.birds, ['blackbird', 'goldfinch'])
assert.deepStrictEqual(s3.birds, ['blackbird', 'starling', 'goldfinch'])
s4 = Automerge.load(Automerge.save(s3))
assert.deepStrictEqual(s3, s4);
})
it('should handle insertion after a deleted list element', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['blackbird', 'thrush', 'goldfinch'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.splice(1, 2))
s2 = Automerge.change(s2, doc => doc.birds.splice(2, 0, 'starling'))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3, {birds: ['blackbird', 'starling']})
assert.deepStrictEqual(Automerge.merge(s2, s3), {birds: ['blackbird', 'starling']})
})
it('should handle concurrent deletion of the same element', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['albatross', 'buzzard', 'cormorant'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.deleteAt(1)) // buzzard
s2 = Automerge.change(s2, doc => doc.birds.deleteAt(1)) // buzzard
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3.birds, ['albatross', 'cormorant'])
})
it('should handle concurrent deletion of different elements', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['albatross', 'buzzard', 'cormorant'])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.deleteAt(0)) // albatross
s2 = Automerge.change(s2, doc => doc.birds.deleteAt(1)) // buzzard
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3.birds, ['cormorant'])
})
it('should handle concurrent updates at different levels of the tree', () => {
// A delete higher up in the tree overrides an update in a subtree
s1 = Automerge.change(s1, doc => doc.animals = {birds: {pink: 'flamingo', black: 'starling'}, mammals: ['badger']})
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.animals.birds.brown = 'sparrow')
s2 = Automerge.change(s2, doc => delete doc.animals.birds)
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1.animals, {
birds: {
pink: 'flamingo', brown: 'sparrow', black: 'starling'
},
mammals: ['badger']
})
assert.deepStrictEqual(s2.animals, {mammals: ['badger']})
assert.deepStrictEqual(s3.animals, {mammals: ['badger']})
})
it('should handle updates of concurrently deleted objects', () => {
s1 = Automerge.change(s1, doc => doc.birds = {blackbird: {feathers: 'black'}})
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => delete doc.birds.blackbird)
s2 = Automerge.change(s2, doc => doc.birds.blackbird.beak = 'orange')
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1, {birds: {}})
})
it('should not interleave sequence insertions at the same position', () => {
s1 = Automerge.change(s1, doc => doc.wisdom = [])
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.wisdom.push('to', 'be', 'is', 'to', 'do'))
s2 = Automerge.change(s2, doc => doc.wisdom.push('to', 'do', 'is', 'to', 'be'))
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(s3.wisdom,
['to', 'be', 'is', 'to', 'do', 'to', 'do', 'is', 'to', 'be'],
['to', 'do', 'is', 'to', 'be', 'to', 'be', 'is', 'to', 'do'])
// In case you're wondering: http://quoteinvestigator.com/2013/09/16/do-be-do/
})
describe('multiple insertions at the same list position', () => {
it('should handle insertion by greater actor ID', () => {
s1 = Automerge.init('aaaa')
s2 = Automerge.init('bbbb')
s1 = Automerge.change(s1, doc => doc.list = ['two'])
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, 'one'))
assert.deepStrictEqual(s2.list, ['one', 'two'])
})
it('should handle insertion by lesser actor ID', () => {
s1 = Automerge.init('bbbb')
s2 = Automerge.init('aaaa')
s1 = Automerge.change(s1, doc => doc.list = ['two'])
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, 'one'))
assert.deepStrictEqual(s2.list, ['one', 'two'])
})
it('should handle insertion regardless of actor ID', () => {
s1 = Automerge.change(s1, doc => doc.list = ['two'])
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, 'one'))
assert.deepStrictEqual(s2.list, ['one', 'two'])
})
it('should make insertion order consistent with causality', () => {
s1 = Automerge.change(s1, doc => doc.list = ['four'])
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.unshift('three'))
s1 = Automerge.merge(s1, s2)
s1 = Automerge.change(s1, doc => doc.list.unshift('two'))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.unshift('one'))
assert.deepStrictEqual(s2.list, ['one', 'two', 'three', 'four'])
})
})
})
describe('saving and loading', () => {
it('should save and restore an empty document', () => {
let s = Automerge.load(Automerge.save(Automerge.init()))
assert.deepStrictEqual(s, {})
})
it('should generate a new random actor ID', () => {
let s1 = Automerge.init()
let s2 = Automerge.load(Automerge.save(s1))
assert.strictEqual(UUID_PATTERN.test(Automerge.getActorId(s1).toString()), true)
assert.strictEqual(UUID_PATTERN.test(Automerge.getActorId(s2).toString()), true)
assert.notEqual(Automerge.getActorId(s1), Automerge.getActorId(s2))
})
it('should allow a custom actor ID to be set', () => {
let s = Automerge.load(Automerge.save(Automerge.init()), '333333')
assert.strictEqual(Automerge.getActorId(s), '333333')
})
it('should reconstitute complex datatypes', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.todos = [{title: 'water plants', done: false}])
let s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s2, {todos: [{title: 'water plants', done: false}]})
})
it('should save and load maps with @ symbols in the keys', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc["123@4567"] = "hello")
let s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s2, { "123@4567": "hello" })
})
it('should reconstitute conflicts', () => {
let s1 = Automerge.change(Automerge.init('111111'), doc => doc.x = 3)
let s2 = Automerge.change(Automerge.init('222222'), doc => doc.x = 5)
s1 = Automerge.merge(s1, s2)
let s3 = Automerge.load(Automerge.save(s1))
assert.strictEqual(s1.x, 5)
assert.strictEqual(s3.x, 5)
assert.deepStrictEqual(Automerge.getConflicts(s1, 'x'), {'1@111111': 3, '1@222222': 5})
assert.deepStrictEqual(Automerge.getConflicts(s3, 'x'), {'1@111111': 3, '1@222222': 5})
})
it('should reconstitute element ID counters', () => {
const s1 = Automerge.init('01234567')
const s2 = Automerge.change(s1, doc => doc.list = ['a'])
const listId = Automerge.getObjectId(s2.list)
const changes12 = Automerge.getAllChanges(s2).map(decodeChange)
assert.deepStrictEqual(changes12, [{
hash: changes12[0].hash, actor: '01234567', seq: 1, startOp: 1,
time: changes12[0].time, message: '', deps: [], ops: [
{obj: '_root', action: 'makeList', key: 'list', insert: false, pred: []},
{obj: listId, action: 'set', elemId: '_head', insert: true, value: 'a', pred: []}
]
}])
const s3 = Automerge.change(s2, doc => doc.list.deleteAt(0))
const s4 = Automerge.load(Automerge.save(s3), '01234567')
const s5 = Automerge.change(s4, doc => doc.list.push('b'))
const changes45 = Automerge.getAllChanges(s5).map(decodeChange)
assert.deepStrictEqual(s5, {list: ['b']})
assert.deepStrictEqual(changes45[2], {
hash: changes45[2].hash, actor: '01234567', seq: 3, startOp: 4,
time: changes45[2].time, message: '', deps: [changes45[1].hash], ops: [
{obj: listId, action: 'set', elemId: '_head', insert: true, value: 'b', pred: []}
]
})
})
it('should allow a reloaded list to be mutated', () => {
let doc = Automerge.change(Automerge.init(), doc => doc.foo = [])
doc = Automerge.load(Automerge.save(doc))
doc = Automerge.change(doc, 'add', doc => doc.foo.push(1))
doc = Automerge.load(Automerge.save(doc))
assert.deepStrictEqual(doc.foo, [1])
})
it('should reload a document containing deflated columns', () => {
// In this test, the keyCtr column is long enough for deflate compression to kick in, but the
// keyStr column is short. Thus, the deflate bit gets set for keyCtr but not for keyStr.
// When checking whether the columns appear in ascending order, we must ignore the deflate bit.
let doc = Automerge.change(Automerge.init(), doc => {
doc.list = []
for (let i = 0; i < 200; i++) doc.list.insertAt(Math.floor(Math.random() * i), 'a')
})
Automerge.load(Automerge.save(doc))
let expected = []
for (let i = 0; i < 200; i++) expected.push('a')
assert.deepStrictEqual(doc, {list: expected})
})
it.skip('should call patchCallback if supplied to load', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Goldfinch'])
const s2 = Automerge.change(s1, doc => doc.birds.push('Chaffinch'))
const callbacks = [], actor = Automerge.getActorId(s1)
const reloaded = Automerge.load(Automerge.save(s2), {
patchCallback(patch, before, after, local) {
callbacks.push({patch, before, after, local})
}
})
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, {
maxOp: 3, deps: [decodeChange(Automerge.getAllChanges(s2)[1]).hash], clock: {[actor]: 2}, pendingChanges: 0,
diffs: {objectId: '_root', type: 'map', props: {birds: {[`1@${actor}`]: {
objectId: `1@${actor}`, type: 'list', edits: [
{action: 'multi-insert', index: 0, elemId: `2@${actor}`, values: ['Goldfinch', 'Chaffinch']}
]
}}}}
})
assert.deepStrictEqual(callbacks[0].before, {})
assert.strictEqual(callbacks[0].after, reloaded)
assert.strictEqual(callbacks[0].local, false)
})
})
describe('history API', () => {
it('should return an empty history for an empty document', () => {
assert.deepStrictEqual(Automerge.getHistory(Automerge.init()), [])
})
it('should make past document states accessible', () => {
let s = Automerge.init()
s = Automerge.change(s, doc => doc.config = {background: 'blue'})
s = Automerge.change(s, doc => doc.birds = ['mallard'])
s = Automerge.change(s, doc => doc.birds.unshift('oystercatcher'))
assert.deepStrictEqual(Automerge.getHistory(s).map(state => state.snapshot), [
{config: {background: 'blue'}},
{config: {background: 'blue'}, birds: ['mallard']},
{config: {background: 'blue'}, birds: ['oystercatcher', 'mallard']}
])
})
it('should make change messages accessible', () => {
let s = Automerge.init()
s = Automerge.change(s, 'Empty Bookshelf', doc => doc.books = [])
s = Automerge.change(s, 'Add Orwell', doc => doc.books.push('Nineteen Eighty-Four'))
s = Automerge.change(s, 'Add Huxley', doc => doc.books.push('Brave New World'))
assert.deepStrictEqual(s.books, ['Nineteen Eighty-Four', 'Brave New World'])
assert.deepStrictEqual(Automerge.getHistory(s).map(state => state.change.message),
['Empty Bookshelf', 'Add Orwell', 'Add Huxley'])
})
})
describe('changes API', () => {
it('should return an empty list on an empty document', () => {
let changes = Automerge.getAllChanges(Automerge.init())
assert.deepStrictEqual(changes, [])
})
it('should return an empty list when nothing changed', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Chaffinch'])
assert.deepStrictEqual(Automerge.getChanges(s1, s1), [])
})
it('should do nothing when applying an empty list of changes', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Chaffinch'])
assert.deepStrictEqual(Automerge.applyChanges(s1, [])[0], s1)
})
it('should return all changes when compared to an empty document', () => {
let s1 = Automerge.change(Automerge.init(), 'Add Chaffinch', doc => doc.birds = ['Chaffinch'])
let s2 = Automerge.change(s1, 'Add Bullfinch', doc => doc.birds.push('Bullfinch'))
let changes = Automerge.getChanges(Automerge.init(), s2)
assert.strictEqual(changes.length, 2)
})
it('should allow a document copy to be reconstructed from scratch', () => {
let s1 = Automerge.change(Automerge.init(), 'Add Chaffinch', doc => doc.birds = ['Chaffinch'])
let s2 = Automerge.change(s1, 'Add Bullfinch', doc => doc.birds.push('Bullfinch'))
let changes = Automerge.getAllChanges(s2)
let [s3] = Automerge.applyChanges(Automerge.init(), changes)
assert.deepStrictEqual(s3.birds, ['Chaffinch', 'Bullfinch'])
})
it('should return changes since the last given version', () => {
let s1 = Automerge.change(Automerge.init(), 'Add Chaffinch', doc => doc.birds = ['Chaffinch'])
let changes1 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, 'Add Bullfinch', doc => doc.birds.push('Bullfinch'))
let changes2 = Automerge.getChanges(s1, s2)
assert.strictEqual(changes1.length, 1) // Add Chaffinch
assert.strictEqual(changes2.length, 1) // Add Bullfinch
})
it('should incrementally apply changes since the last given version', () => {
let s1 = Automerge.change(Automerge.init(), 'Add Chaffinch', doc => doc.birds = ['Chaffinch'])
let changes1 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, 'Add Bullfinch', doc => doc.birds.push('Bullfinch'))
let changes2 = Automerge.getChanges(s1, s2)
let [s3] = Automerge.applyChanges(Automerge.init(), changes1)
let [s4] = Automerge.applyChanges(s3, changes2)
assert.deepStrictEqual(s3.birds, ['Chaffinch'])
assert.deepStrictEqual(s4.birds, ['Chaffinch', 'Bullfinch'])
})
it('should handle updates to a list element', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Chaffinch', 'Bullfinch'])
let s2 = Automerge.change(s1, doc => doc.birds[0] = 'Goldfinch')
let [s3] = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2))
assert.deepStrictEqual(s3.birds, ['Goldfinch', 'Bullfinch'])
assert.strictEqual(Automerge.getConflicts(s3.birds, 0), undefined)
})
// TEXT
it('should handle updates to a text object', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text('ab'))
let s2 = Automerge.change(s1, doc => doc.text.set(0, 'A'))
let [s3] = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2))
assert.deepStrictEqual([...s3.text], ['A', 'b'])
})
/*
it.skip('should report missing dependencies', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Chaffinch'])
let s2 = Automerge.merge(Automerge.init(), s1)
s2 = Automerge.change(s2, doc => doc.birds.push('Bullfinch'))
let changes = Automerge.getAllChanges(s2)
let [s3, patch] = Automerge.applyChanges(Automerge.init(), [changes[1]])
assert.deepStrictEqual(s3, {})
assert.deepStrictEqual(Automerge.Backend.getMissingDeps(Automerge.Frontend.getBackendState(s3)),
decodeChange(changes[1]).deps)
assert.strictEqual(patch.pendingChanges, 1)
;[s3, patch] = Automerge.applyChanges(s3, [changes[0]])
assert.deepStrictEqual(s3.birds, ['Chaffinch', 'Bullfinch'])
assert.deepStrictEqual(Automerge.Backend.getMissingDeps(Automerge.Frontend.getBackendState(s3)), [])
assert.strictEqual(patch.pendingChanges, 0)
})
*/
it('should report missing dependencies with out-of-order applyChanges', () => {
let s0 = Automerge.init()
let s1 = Automerge.change(s0, doc => doc.test = ['a'])
let changes01 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, doc => doc.test = ['b'])
let changes12 = Automerge.getChanges(s1, s2)
let s3 = Automerge.change(s2, doc => doc.test = ['c'])
let changes23 = Automerge.getChanges(s2, s3)
let s4 = Automerge.init()
let [s5] = Automerge.applyChanges(s4, changes23)
let [s6] = Automerge.applyChanges(s5, changes12)
assert.deepStrictEqual(Automerge.getMissingDeps(s6), [decodeChange(changes01[0]).hash])
})
it('should call patchCallback if supplied when applying changes', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Goldfinch'])
const callbacks = [], actor = Automerge.getActorId(s1)
const before = Automerge.init()
const [after, patch] = Automerge.applyChanges(before, Automerge.getAllChanges(s1), {
patchCallback(patch, before, after) {
callbacks.push({patch, before, after})
}
})
assert.strictEqual(callbacks.length, 2)
assert.deepStrictEqual(callbacks[0].patch, { action: 'put', path: ["birds"], value: [], conflict: false })
assert.deepStrictEqual(callbacks[1].patch, { action: 'splice', path: ["birds",0], values: ["Goldfinch"] })
assert.strictEqual(callbacks[0].before, before)
assert.strictEqual(callbacks[1].after, after)
})
it('should merge multiple applied changes into one patch', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Goldfinch'])
const s2 = Automerge.change(s1, doc => doc.birds.push('Chaffinch'))
const patches = [], actor = Automerge.getActorId(s2)
Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2),
{patchCallback: p => patches.push(p)})
assert.deepStrictEqual(patches, [
{ action: 'put', conflict: false, path: [ 'birds' ], value: [] },
{ action: "splice", path: [ "birds", 0 ], values: [ "Goldfinch", "Chaffinch" ] }
])
})
it('should call a patchCallback registered on doc initialisation', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.bird = 'Goldfinch')
const patches = [], actor = Automerge.getActorId(s1)
const before = Automerge.init({patchCallback: p => patches.push(p)})
Automerge.applyChanges(before, Automerge.getAllChanges(s1))
assert.deepStrictEqual(patches, [{
action: "put",
conflict: false,
path: [ "bird" ],
value: "Goldfinch" }
])
})
})
})