1e33c9d9e0
Problem: when running the sync protocol for a new document the API requires that the user create an empty document and then call `receive_sync_message` on that document. This results in the OpObserver for the new document being called with every single op in the document history. For documents with a large history this can be extremely time consuming, but the OpObserver doesn't need to know about all the hidden states. Solution: Modify `Automerge::load_with` and `Automerge::apply_changes_with` to check if the document is empty before applying changes. If the document _is_ empty then we don't call the observer for every change, but instead use `automerge::observe_current_state` to notify the observer of the new state once all the changes have been applied.
1874 lines
68 KiB
TypeScript
1874 lines
68 KiB
TypeScript
import * as assert from "assert"
|
|
import { unstable 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][0-9a-f])*$/
|
|
|
|
// 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", () => {
|
|
// @ts-ignore
|
|
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", () => {
|
|
// @ts-ignore
|
|
const doc = Automerge.from("abc")
|
|
assert.deepStrictEqual(doc, { "0": "a", "1": "b", "2": "c" })
|
|
})
|
|
|
|
it("ignores numbers provided as initial values", () => {
|
|
// @ts-ignore
|
|
const doc = Automerge.from(123)
|
|
assert.deepStrictEqual(doc, {})
|
|
})
|
|
|
|
it("ignores booleans provided as initial values", () => {
|
|
// @ts-ignore
|
|
const doc1 = Automerge.from(false)
|
|
assert.deepStrictEqual(doc1, {})
|
|
// @ts-ignore
|
|
const doc2 = Automerge.from(true)
|
|
assert.deepStrictEqual(doc2, {})
|
|
})
|
|
})
|
|
|
|
describe("sequential use", () => {
|
|
let s1: Automerge.Doc<any>, s2: Automerge.Doc<any>
|
|
beforeEach(() => {
|
|
s1 = Automerge.init("aabbcc")
|
|
})
|
|
|
|
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 = Automerge.decodeChange(change2!)
|
|
assert.deepStrictEqual(change, {
|
|
actor: change.actor,
|
|
deps: [],
|
|
seq: 1,
|
|
startOp: 1,
|
|
hash: change.hash,
|
|
message: null,
|
|
time: change.time,
|
|
ops: [
|
|
{ obj: "_root", key: "foo", action: "makeText", pred: [] },
|
|
{
|
|
action: "set",
|
|
elemId: "_head",
|
|
insert: true,
|
|
obj: "1@aabbcc",
|
|
pred: [],
|
|
value: "b",
|
|
},
|
|
{
|
|
action: "set",
|
|
elemId: "2@aabbcc",
|
|
insert: true,
|
|
obj: "1@aabbcc",
|
|
pred: [],
|
|
value: "a",
|
|
},
|
|
{
|
|
action: "set",
|
|
elemId: "3@aabbcc",
|
|
insert: true,
|
|
obj: "1@aabbcc",
|
|
pred: [],
|
|
value: "r",
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
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 {
|
|
// @ts-ignore
|
|
s2.foo = "lemon"
|
|
} catch (e) {}
|
|
assert.strictEqual(s2.foo, "bar")
|
|
|
|
let deleted = false
|
|
try {
|
|
// @ts-ignore
|
|
deleted = delete s2.foo
|
|
} catch (e) {}
|
|
assert.strictEqual(s2.foo, "bar")
|
|
assert.strictEqual(deleted, false)
|
|
|
|
Automerge.change(s2, () => {
|
|
try {
|
|
// @ts-ignore
|
|
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(() => {
|
|
// @ts-ignore
|
|
Automerge.change({}, doc => (doc.foo = "bar"))
|
|
}, /must be the document root/)
|
|
assert.throws(() => {
|
|
// @ts-ignore
|
|
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 => {
|
|
// @ts-ignore
|
|
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 change an outdated 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 change an outdated 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: Array<{
|
|
patches: Array<Automerge.Patch>
|
|
before: Automerge.Doc<any>
|
|
after: Automerge.Doc<any>
|
|
}> = []
|
|
const s2 = Automerge.change(
|
|
s1,
|
|
{
|
|
patchCallback: (patches, before, after) =>
|
|
callbacks.push({ patches, before, after }),
|
|
},
|
|
doc => {
|
|
doc.birds = ["Goldfinch"]
|
|
}
|
|
)
|
|
assert.strictEqual(callbacks.length, 1)
|
|
assert.deepStrictEqual(callbacks[0].patches[0], {
|
|
action: "put",
|
|
path: ["birds"],
|
|
value: [],
|
|
})
|
|
assert.deepStrictEqual(callbacks[0].patches[1], {
|
|
action: "insert",
|
|
path: ["birds", 0],
|
|
values: [""],
|
|
})
|
|
assert.deepStrictEqual(callbacks[0].patches[2], {
|
|
action: "splice",
|
|
path: ["birds", 0, 0],
|
|
value: "Goldfinch",
|
|
})
|
|
assert.strictEqual(callbacks[0].before, s1)
|
|
assert.strictEqual(callbacks[0].after, s2)
|
|
})
|
|
|
|
it("should call a patchCallback set up on document initialisation", () => {
|
|
const callbacks: Array<{
|
|
patches: Array<Automerge.Patch>
|
|
before: Automerge.Doc<any>
|
|
after: Automerge.Doc<any>
|
|
}> = []
|
|
s1 = Automerge.init({
|
|
patchCallback: (patches, before, after) =>
|
|
callbacks.push({ patches, before, after }),
|
|
})
|
|
const s2 = Automerge.change(s1, doc => (doc.bird = "Goldfinch"))
|
|
assert.strictEqual(callbacks.length, 1)
|
|
assert.deepStrictEqual(callbacks[0].patches[0], {
|
|
action: "put",
|
|
path: ["bird"],
|
|
value: "",
|
|
})
|
|
assert.deepStrictEqual(callbacks[0].patches[1], {
|
|
action: "splice",
|
|
path: ["bird", 0],
|
|
value: "Goldfinch",
|
|
})
|
|
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: any
|
|
s1 = Automerge.change(s1, "del foo", doc => {
|
|
deleted = delete doc.foo
|
|
})
|
|
assert.strictEqual(deleted, true)
|
|
let deleted2: any
|
|
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 = {}
|
|
})
|
|
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" }
|
|
})
|
|
let 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 },
|
|
})
|
|
// @ts-ignore
|
|
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", () => {
|
|
let s1 = Automerge.change(
|
|
Automerge.init<{ noodles: Array<string> }>(),
|
|
// @ts-ignore
|
|
doc => (doc.noodles = [])
|
|
)
|
|
s1 = Automerge.change(s1, doc => {
|
|
doc.noodles.push("udon")
|
|
// @ts-ignore
|
|
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 => {
|
|
// @ts-ignore
|
|
doc.noodles.push("soba")
|
|
// @ts-ignore
|
|
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<any>(),
|
|
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: Automerge.Doc<any>,
|
|
s2: Automerge.Doc<any>,
|
|
s3: Automerge.Doc<any>,
|
|
s4: Automerge.Doc<any>
|
|
beforeEach(() => {
|
|
s1 = Automerge.init<any>()
|
|
s2 = Automerge.init<any>()
|
|
s3 = Automerge.init<any>()
|
|
s4 = Automerge.init<any>()
|
|
})
|
|
|
|
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), {
|
|
[`8@${Automerge.getActorId(s1)}`]: "greenfinch",
|
|
[`8@${Automerge.getActorId(s2)}`]: "goldfinch_",
|
|
})
|
|
})
|
|
|
|
it("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 },
|
|
})
|
|
})
|
|
|
|
it("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<any>(s3.list, 0), {
|
|
[`8@${Automerge.getActorId(s1)}`]: { map1: true, key: 1 },
|
|
[`8@${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<any>(),
|
|
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<any>(),
|
|
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<any>("111111"),
|
|
doc => (doc.x = 3)
|
|
)
|
|
let s2 = Automerge.change(
|
|
Automerge.init<any>("222222"),
|
|
doc => (doc.x = 5)
|
|
)
|
|
s1 = Automerge.merge(s1, s2)
|
|
let s3 = Automerge.load<any>(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<any>("01234567")
|
|
const s2 = Automerge.change(s1, doc => (doc.list = ["a"]))
|
|
const listId = Automerge.getObjectId(s2.list)
|
|
const changes12 = Automerge.getAllChanges(s2).map(Automerge.decodeChange)
|
|
assert.deepStrictEqual(changes12, [
|
|
{
|
|
hash: changes12[0].hash,
|
|
actor: "01234567",
|
|
seq: 1,
|
|
startOp: 1,
|
|
time: changes12[0].time,
|
|
message: null,
|
|
deps: [],
|
|
ops: [
|
|
{ obj: "_root", action: "makeList", key: "list", pred: [] },
|
|
{
|
|
obj: listId,
|
|
action: "makeText",
|
|
elemId: "_head",
|
|
insert: true,
|
|
pred: [],
|
|
},
|
|
{
|
|
obj: "2@01234567",
|
|
action: "set",
|
|
elemId: "_head",
|
|
insert: true,
|
|
value: "a",
|
|
pred: [],
|
|
},
|
|
],
|
|
},
|
|
])
|
|
const s3 = Automerge.change(s2, doc => doc.list.deleteAt(0))
|
|
const s4 = Automerge.load<any>(Automerge.save(s3), "01234567")
|
|
const s5 = Automerge.change(s4, doc => doc.list.push("b"))
|
|
const changes45 = Automerge.getAllChanges(s5).map(Automerge.decodeChange)
|
|
assert.deepStrictEqual(s5, { list: ["b"] })
|
|
assert.deepStrictEqual(changes45[2], {
|
|
hash: changes45[2].hash,
|
|
actor: "01234567",
|
|
seq: 3,
|
|
startOp: 5,
|
|
time: changes45[2].time,
|
|
message: null,
|
|
deps: [changes45[1].hash],
|
|
ops: [
|
|
{
|
|
obj: listId,
|
|
action: "makeText",
|
|
elemId: "_head",
|
|
insert: true,
|
|
pred: [],
|
|
},
|
|
{
|
|
obj: "5@01234567",
|
|
action: "set",
|
|
elemId: "_head",
|
|
insert: true,
|
|
value: "b",
|
|
pred: [],
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it("should allow a reloaded list to be mutated", () => {
|
|
let doc = Automerge.change(Automerge.init<any>(), 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<any>(), doc => {
|
|
doc.list = []
|
|
for (let i = 0; i < 200; i++)
|
|
doc.list.insertAt(Math.floor(Math.random() * i), "a")
|
|
})
|
|
Automerge.load<any>(Automerge.save(doc))
|
|
let expected: Array<string> = []
|
|
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<any>(),
|
|
doc => (doc.birds = ["Goldfinch"])
|
|
)
|
|
const s2 = Automerge.change(s1, doc => doc.birds.push("Chaffinch"))
|
|
const callbacks: Array<any> = [],
|
|
actor = Automerge.getActorId(s1)
|
|
const reloaded = Automerge.load<any>(Automerge.save(s2), {
|
|
patchCallback(patch, before, after) {
|
|
callbacks.push({ patch, before, after })
|
|
},
|
|
})
|
|
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<any>()
|
|
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<any>()
|
|
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<any>(),
|
|
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<any>(),
|
|
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<any>(),
|
|
"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<any>(),
|
|
"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<any>(), changes)
|
|
assert.deepStrictEqual(s3.birds, ["Chaffinch", "Bullfinch"])
|
|
})
|
|
|
|
it("should return changes since the last given version", () => {
|
|
let s1 = Automerge.change(
|
|
Automerge.init<any>(),
|
|
"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<any>(),
|
|
"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<any>(), 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<any>(),
|
|
doc => (doc.birds = ["Chaffinch", "Bullfinch"])
|
|
)
|
|
let s2 = Automerge.change(s1, doc => (doc.birds[0] = "Goldfinch"))
|
|
let [s3] = Automerge.applyChanges(
|
|
Automerge.init<any>(),
|
|
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<any>(), doc => (doc.text = "ab"))
|
|
let s2 = Automerge.change(s1, doc =>
|
|
Automerge.splice(doc, "text", 0, 1, "A")
|
|
)
|
|
let [s3] = Automerge.applyChanges(
|
|
Automerge.init<any>(),
|
|
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<any>()
|
|
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<any>(),
|
|
doc => (doc.birds = ["Goldfinch"])
|
|
)
|
|
const callbacks: Array<any> = []
|
|
const before = Automerge.init()
|
|
const [after] = Automerge.applyChanges(
|
|
before,
|
|
Automerge.getAllChanges(s1),
|
|
{
|
|
patchCallback(patch, before, after) {
|
|
callbacks.push({ patch, before, after })
|
|
},
|
|
}
|
|
)
|
|
assert.strictEqual(callbacks.length, 1)
|
|
assert.deepStrictEqual(callbacks[0].patch[0], {
|
|
action: "put",
|
|
path: ["birds"],
|
|
value: [],
|
|
})
|
|
assert.deepStrictEqual(callbacks[0].patch[1], {
|
|
action: "insert",
|
|
path: ["birds", 0],
|
|
values: [""],
|
|
})
|
|
assert.deepStrictEqual(callbacks[0].patch[2], {
|
|
action: "splice",
|
|
path: ["birds", 0, 0],
|
|
value: "Goldfinch",
|
|
})
|
|
assert.strictEqual(callbacks[0].before, before)
|
|
assert.strictEqual(callbacks[0].after, after)
|
|
})
|
|
|
|
it("should merge multiple applied changes into one patch", () => {
|
|
const s1 = Automerge.change(
|
|
Automerge.init<any>(),
|
|
doc => (doc.birds = ["Goldfinch"])
|
|
)
|
|
const s2 = Automerge.change(s1, doc => doc.birds.push("Chaffinch"))
|
|
const patches: Array<any> = []
|
|
Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2), {
|
|
patchCallback: p => patches.push(...p),
|
|
})
|
|
assert.deepStrictEqual(patches, [
|
|
{ action: "put", path: ["birds"], value: [] },
|
|
{ action: "insert", path: ["birds", 0], values: ["", ""] },
|
|
{ action: "splice", path: ["birds", 0, 0], value: "Goldfinch" },
|
|
{ action: "splice", path: ["birds", 1, 0], value: "Chaffinch" },
|
|
])
|
|
})
|
|
|
|
it("should call a patchCallback registered on doc initialisation", () => {
|
|
const s1 = Automerge.change(
|
|
Automerge.init<any>(),
|
|
doc => (doc.bird = "Goldfinch")
|
|
)
|
|
const patches: Array<any> = []
|
|
const before = Automerge.init({
|
|
patchCallback: p => patches.push(...p),
|
|
})
|
|
Automerge.applyChanges(before, Automerge.getAllChanges(s1))
|
|
assert.deepStrictEqual(patches, [
|
|
{ action: "put", path: ["bird"], value: "" },
|
|
{ action: "splice", path: ["bird", 0], value: "Goldfinch" },
|
|
])
|
|
})
|
|
})
|
|
})
|