298 lines
8.5 KiB
TypeScript
298 lines
8.5 KiB
TypeScript
import * as assert from 'assert'
|
|
import * as Automerge from '../src'
|
|
import { assertEqualsOneOf } from './helpers'
|
|
|
|
function attributeStateToAttributes(accumulatedAttributes) {
|
|
const attributes = {}
|
|
Object.entries(accumulatedAttributes).forEach(([key, values]) => {
|
|
if (values.length && values[0] !== null) {
|
|
attributes[key] = values[0]
|
|
}
|
|
})
|
|
return attributes
|
|
}
|
|
|
|
function isEquivalent(a, b) {
|
|
const aProps = Object.getOwnPropertyNames(a)
|
|
const bProps = Object.getOwnPropertyNames(b)
|
|
|
|
if (aProps.length != bProps.length) {
|
|
return false
|
|
}
|
|
|
|
for (let i = 0; i < aProps.length; i++) {
|
|
const propName = aProps[i]
|
|
if (a[propName] !== b[propName]) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function isControlMarker(pseudoCharacter) {
|
|
return typeof pseudoCharacter === 'object' && pseudoCharacter.attributes
|
|
}
|
|
|
|
function opFrom(text, attributes) {
|
|
let op = { insert: text }
|
|
if (Object.keys(attributes).length > 0) {
|
|
op.attributes = attributes
|
|
}
|
|
return op
|
|
}
|
|
|
|
function accumulateAttributes(span, accumulatedAttributes) {
|
|
Object.entries(span).forEach(([key, value]) => {
|
|
if (!accumulatedAttributes[key]) {
|
|
accumulatedAttributes[key] = []
|
|
}
|
|
if (value === null) {
|
|
if (accumulatedAttributes[key].length === 0 || accumulatedAttributes[key] === null) {
|
|
accumulatedAttributes[key].unshift(null)
|
|
} else {
|
|
accumulatedAttributes[key].shift()
|
|
}
|
|
} else {
|
|
if (accumulatedAttributes[key][0] === null) {
|
|
accumulatedAttributes[key].shift()
|
|
} else {
|
|
accumulatedAttributes[key].unshift(value)
|
|
}
|
|
}
|
|
})
|
|
return accumulatedAttributes
|
|
}
|
|
|
|
function automergeTextToDeltaDoc(text) {
|
|
let ops = []
|
|
let controlState = {}
|
|
let currentString = ""
|
|
let attributes = {}
|
|
text.toSpans().forEach((span) => {
|
|
if (isControlMarker(span)) {
|
|
controlState = accumulateAttributes(span.attributes, controlState)
|
|
} else {
|
|
let next = attributeStateToAttributes(controlState)
|
|
|
|
// if the next span has the same calculated attributes as the current span
|
|
// don't bother outputting it as a separate span, just let it ride
|
|
if (typeof span === 'string' && isEquivalent(next, attributes)) {
|
|
currentString = currentString + span
|
|
return
|
|
}
|
|
|
|
if (currentString) {
|
|
ops.push(opFrom(currentString, attributes))
|
|
}
|
|
|
|
// If we've got a string, we might be able to concatenate it to another
|
|
// same-attributed-string, so remember it and go to the next iteration.
|
|
if (typeof span === 'string') {
|
|
currentString = span
|
|
attributes = next
|
|
} else {
|
|
// otherwise we have an embed "character" and should output it immediately.
|
|
// embeds are always one-"character" in length.
|
|
ops.push(opFrom(span, next))
|
|
currentString = ''
|
|
attributes = {}
|
|
}
|
|
}
|
|
})
|
|
|
|
// at the end, flush any accumulated string out
|
|
if (currentString) {
|
|
ops.push(opFrom(currentString, attributes))
|
|
}
|
|
|
|
return ops
|
|
}
|
|
|
|
function inverseAttributes(attributes) {
|
|
let invertedAttributes = {}
|
|
Object.keys(attributes).forEach((key) => {
|
|
invertedAttributes[key] = null
|
|
})
|
|
return invertedAttributes
|
|
}
|
|
|
|
function applyDeleteOp(text, offset, op) {
|
|
let length = op.delete
|
|
while (length > 0) {
|
|
if (isControlMarker(text.get(offset))) {
|
|
offset += 1
|
|
} else {
|
|
// we need to not delete control characters, but we do delete embed characters
|
|
text.deleteAt(offset, 1)
|
|
length -= 1
|
|
}
|
|
}
|
|
return [text, offset]
|
|
}
|
|
|
|
function applyRetainOp(text, offset, op) {
|
|
let length = op.retain
|
|
|
|
if (op.attributes) {
|
|
text.insertAt(offset, { attributes: op.attributes })
|
|
offset += 1
|
|
}
|
|
|
|
while (length > 0) {
|
|
const char = text.get(offset)
|
|
offset += 1
|
|
if (!isControlMarker(char)) {
|
|
length -= 1
|
|
}
|
|
}
|
|
|
|
if (op.attributes) {
|
|
text.insertAt(offset, { attributes: inverseAttributes(op.attributes) })
|
|
offset += 1
|
|
}
|
|
|
|
return [text, offset]
|
|
}
|
|
|
|
|
|
function applyInsertOp(text, offset, op) {
|
|
let originalOffset = offset
|
|
|
|
if (typeof op.insert === 'string') {
|
|
text.insertAt(offset, ...op.insert.split(''))
|
|
offset += op.insert.length
|
|
} else {
|
|
// we have an embed or something similar
|
|
text.insertAt(offset, op.insert)
|
|
offset += 1
|
|
}
|
|
|
|
if (op.attributes) {
|
|
text.insertAt(originalOffset, { attributes: op.attributes })
|
|
offset += 1
|
|
}
|
|
if (op.attributes) {
|
|
text.insertAt(offset, { attributes: inverseAttributes(op.attributes) })
|
|
offset += 1
|
|
}
|
|
return [text, offset]
|
|
}
|
|
|
|
// XXX: uhhhhh, why can't I pass in text?
|
|
function applyDeltaDocToAutomergeText(delta, doc) {
|
|
let offset = 0
|
|
|
|
delta.forEach(op => {
|
|
if (op.retain) {
|
|
[, offset] = applyRetainOp(doc.text, offset, op)
|
|
} else if (op.delete) {
|
|
[, offset] = applyDeleteOp(doc.text, offset, op)
|
|
} else if (op.insert) {
|
|
[, offset] = applyInsertOp(doc.text, offset, op)
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('Automerge.Text', () => {
|
|
let s1, s2
|
|
beforeEach(() => {
|
|
s1 = Automerge.change(Automerge.init(), doc => doc.text = "")
|
|
s2 = Automerge.merge(Automerge.init(), s1)
|
|
})
|
|
|
|
it('should support insertion', () => {
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "a"))
|
|
assert.strictEqual(s1.text.length, 1)
|
|
assert.strictEqual(s1.text[0], 'a')
|
|
assert.strictEqual(s1.text, 'a')
|
|
//assert.strictEqual(s1.text.getElemId(0), `2@${Automerge.getActorId(s1)}`)
|
|
})
|
|
|
|
it('should support deletion', () => {
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 1))
|
|
assert.strictEqual(s1.text.length, 2)
|
|
assert.strictEqual(s1.text[0], 'a')
|
|
assert.strictEqual(s1.text[1], 'c')
|
|
assert.strictEqual(s1.text, 'ac')
|
|
})
|
|
|
|
it("should support implicit and explicit deletion", () => {
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 1))
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 0))
|
|
assert.strictEqual(s1.text.length, 2)
|
|
assert.strictEqual(s1.text[0], "a")
|
|
assert.strictEqual(s1.text[1], "c")
|
|
assert.strictEqual(s1.text, "ac")
|
|
})
|
|
|
|
it('should handle concurrent insertion', () => {
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
|
|
s2 = Automerge.change(s2, doc => Automerge.splice(doc, "text", 0, 0, "xyz"))
|
|
s1 = Automerge.merge(s1, s2)
|
|
assert.strictEqual(s1.text.length, 6)
|
|
assertEqualsOneOf(s1.text, 'abcxyz', 'xyzabc')
|
|
})
|
|
|
|
it('should handle text and other ops in the same change', () => {
|
|
s1 = Automerge.change(s1, doc => {
|
|
doc.foo = 'bar'
|
|
Automerge.splice(doc, "text", 0, 0, 'a')
|
|
})
|
|
assert.strictEqual(s1.foo, 'bar')
|
|
assert.strictEqual(s1.text, 'a')
|
|
assert.strictEqual(s1.text, 'a')
|
|
})
|
|
|
|
it('should serialize to JSON as a simple string', () => {
|
|
s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, 'a"b'))
|
|
assert.strictEqual(JSON.stringify(s1), '{"text":"a\\"b"}')
|
|
})
|
|
|
|
it('should allow modification after an object is assigned to a document', () => {
|
|
s1 = Automerge.change(Automerge.init(), doc => {
|
|
doc.text = ""
|
|
Automerge.splice(doc ,"text", 0, 0, 'abcd')
|
|
Automerge.splice(doc ,"text", 2, 1)
|
|
assert.strictEqual(doc.text, 'abd')
|
|
})
|
|
assert.strictEqual(s1.text, 'abd')
|
|
})
|
|
|
|
it('should not allow modification outside of a change callback', () => {
|
|
assert.throws(() => Automerge.splice(s1 ,"text", 0, 0, 'a'), /object cannot be modified outside of a change block/)
|
|
})
|
|
|
|
describe('with initial value', () => {
|
|
|
|
it('should initialize text in Automerge.from()', () => {
|
|
let s1 = Automerge.from({text: 'init'})
|
|
assert.strictEqual(s1.text.length, 4)
|
|
assert.strictEqual(s1.text[0], 'i')
|
|
assert.strictEqual(s1.text[1], 'n')
|
|
assert.strictEqual(s1.text[2], 'i')
|
|
assert.strictEqual(s1.text[3], 't')
|
|
assert.strictEqual(s1.text, 'init')
|
|
})
|
|
|
|
it('should encode the initial value as a change', () => {
|
|
const s1 = Automerge.from({text: 'init'})
|
|
const changes = Automerge.getAllChanges(s1)
|
|
assert.strictEqual(changes.length, 1)
|
|
const [s2] = Automerge.applyChanges(Automerge.init(), changes)
|
|
assert.strictEqual(s2.text, 'init')
|
|
assert.strictEqual(s2.text, 'init')
|
|
})
|
|
|
|
})
|
|
|
|
it('should support unicode when creating text', () => {
|
|
s1 = Automerge.from({
|
|
text: '🐦'
|
|
})
|
|
// TODO utf16 indexing
|
|
assert.strictEqual(s1.text, '🐦')
|
|
})
|
|
})
|