automerge/automerge-js/test/text_test.ts
2022-09-29 16:23:11 -05:00

701 lines
22 KiB
TypeScript

import * as assert from 'assert'
import * as Automerge from '../src'
import { assertEqualsOneOf } from './helpers'
import * as AutomergeWASM from "automerge-wasm"
Automerge.use(AutomergeWASM)
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 = new Automerge.Text())
s2 = Automerge.merge(Automerge.init(), s1)
})
it('should support insertion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a'))
assert.strictEqual(s1.text.length, 1)
assert.strictEqual(s1.text.get(0), 'a')
assert.strictEqual(s1.text.toString(), 'a')
//assert.strictEqual(s1.text.getElemId(0), `2@${Automerge.getActorId(s1)}`)
})
it('should support deletion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', 'b', 'c'))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1, 1))
assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), 'a')
assert.strictEqual(s1.text.get(1), 'c')
assert.strictEqual(s1.text.toString(), 'ac')
})
it("should support implicit and explicit deletion", () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, "a", "b", "c"))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1, 0))
assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), "a")
assert.strictEqual(s1.text.get(1), "c")
assert.strictEqual(s1.text.toString(), "ac")
})
it('should handle concurrent insertion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', 'b', 'c'))
s2 = Automerge.change(s2, doc => doc.text.insertAt(0, 'x', 'y', 'z'))
s1 = Automerge.merge(s1, s2)
assert.strictEqual(s1.text.length, 6)
assertEqualsOneOf(s1.text.toString(), 'abcxyz', 'xyzabc')
assertEqualsOneOf(s1.text.join(''), 'abcxyz', 'xyzabc')
})
it('should handle text and other ops in the same change', () => {
s1 = Automerge.change(s1, doc => {
doc.foo = 'bar'
doc.text.insertAt(0, 'a')
})
assert.strictEqual(s1.foo, 'bar')
assert.strictEqual(s1.text.toString(), 'a')
assert.strictEqual(s1.text.join(''), 'a')
})
it('should serialize to JSON as a simple string', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', '"', 'b'))
assert.strictEqual(JSON.stringify(s1), '{"text":"a\\"b"}')
})
it('should allow modification before an object is assigned to a document', () => {
s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text()
text.insertAt(0, 'a', 'b', 'c', 'd')
text.deleteAt(2)
doc.text = text
assert.strictEqual(doc.text.toString(), 'abd')
assert.strictEqual(doc.text.join(''), 'abd')
})
assert.strictEqual(s1.text.toString(), 'abd')
assert.strictEqual(s1.text.join(''), 'abd')
})
it('should allow modification after an object is assigned to a document', () => {
s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text()
doc.text = text
doc.text.insertAt(0, 'a', 'b', 'c', 'd')
doc.text.deleteAt(2)
assert.strictEqual(doc.text.toString(), 'abd')
assert.strictEqual(doc.text.join(''), 'abd')
})
assert.strictEqual(s1.text.join(''), 'abd')
})
it('should not allow modification outside of a change callback', () => {
assert.throws(() => s1.text.insertAt(0, 'a'), /object cannot be modified outside of a change block/)
})
describe('with initial value', () => {
it('should accept a string as initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text('init'))
assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i')
assert.strictEqual(s1.text.get(1), 'n')
assert.strictEqual(s1.text.get(2), 'i')
assert.strictEqual(s1.text.get(3), 't')
assert.strictEqual(s1.text.toString(), 'init')
})
it('should accept an array as initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text(['i', 'n', 'i', 't']))
assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i')
assert.strictEqual(s1.text.get(1), 'n')
assert.strictEqual(s1.text.get(2), 'i')
assert.strictEqual(s1.text.get(3), 't')
assert.strictEqual(s1.text.toString(), 'init')
})
it('should initialize text in Automerge.from()', () => {
let s1 = Automerge.from({text: new Automerge.Text('init')})
assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i')
assert.strictEqual(s1.text.get(1), 'n')
assert.strictEqual(s1.text.get(2), 'i')
assert.strictEqual(s1.text.get(3), 't')
assert.strictEqual(s1.text.toString(), 'init')
})
it('should encode the initial value as a change', () => {
const s1 = Automerge.from({text: new Automerge.Text('init')})
const changes = Automerge.getAllChanges(s1)
assert.strictEqual(changes.length, 1)
const [s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.text instanceof Automerge.Text, true)
assert.strictEqual(s2.text.toString(), 'init')
assert.strictEqual(s2.text.join(''), 'init')
})
it('should allow immediate access to the value', () => {
Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
assert.strictEqual(text.length, 4)
assert.strictEqual(text.get(0), 'i')
assert.strictEqual(text.toString(), 'init')
doc.text = text
assert.strictEqual(doc.text.length, 4)
assert.strictEqual(doc.text.get(0), 'i')
assert.strictEqual(doc.text.toString(), 'init')
})
})
it('should allow pre-assignment modification of the initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
text.deleteAt(3)
assert.strictEqual(text.join(''), 'ini')
doc.text = text
assert.strictEqual(doc.text.join(''), 'ini')
assert.strictEqual(doc.text.toString(), 'ini')
})
assert.strictEqual(s1.text.toString(), 'ini')
assert.strictEqual(s1.text.join(''), 'ini')
})
it('should allow post-assignment modification of the initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
doc.text = text
doc.text.deleteAt(0)
doc.text.insertAt(0, 'I')
assert.strictEqual(doc.text.join(''), 'Init')
assert.strictEqual(doc.text.toString(), 'Init')
})
assert.strictEqual(s1.text.join(''), 'Init')
assert.strictEqual(s1.text.toString(), 'Init')
})
})
describe('non-textual control characters', () => {
let s1
beforeEach(() => {
s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text()
doc.text.insertAt(0, 'a')
doc.text.insertAt(1, { attribute: 'bold' })
})
})
it('should allow fetching non-textual characters', () => {
assert.deepEqual(s1.text.get(1), { attribute: 'bold' })
//assert.strictEqual(s1.text.getElemId(1), `3@${Automerge.getActorId(s1)}`)
})
it('should include control characters in string length', () => {
assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), 'a')
})
it('should replace control characters from toString()', () => {
assert.strictEqual(s1.text.toString(), 'a\uFFFC')
})
it('should allow control characters to be updated', () => {
const s2 = Automerge.change(s1, doc => doc.text.get(1).attribute = 'italic')
const s3 = Automerge.load(Automerge.save(s2))
assert.strictEqual(s1.text.get(1).attribute, 'bold')
assert.strictEqual(s2.text.get(1).attribute, 'italic')
assert.strictEqual(s3.text.get(1).attribute, 'italic')
})
describe('spans interface to Text', () => {
it('should return a simple string as a single span', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
})
assert.deepEqual(s1.text.toSpans(), ['hello world'])
})
it('should return an empty string as an empty array', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text()
})
assert.deepEqual(s1.text.toSpans(), [])
})
it('should split a span at a control character', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello', { attributes: { bold: true } }, ' world'])
})
it('should allow consecutive control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
doc.text.insertAt(6, { attributes: { italic: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello',
{ attributes: { bold: true } },
{ attributes: { italic: true } },
' world'
])
})
it('should allow non-consecutive control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
doc.text.insertAt(12, { attributes: { italic: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello',
{ attributes: { bold: true } },
' world',
{ attributes: { italic: true } }
])
})
it('should be convertable into a Quill delta', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(7 + 1, { attributes: { bold: null } })
doc.text.insertAt(12 + 2, { attributes: { color: '#cccccc' } })
})
let deltaDoc = automergeTextToDeltaDoc(s1.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Grey', attributes: { color: '#cccccc' } }
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should support embeds', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('')
doc.text.insertAt(0, { attributes: { link: 'https://quilljs.com' } })
doc.text.insertAt(1, {
image: 'https://quilljs.com/assets/images/icon.png'
})
doc.text.insertAt(2, { attributes: { link: null } })
})
let deltaDoc = automergeTextToDeltaDoc(s1.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [{
// An image link
insert: {
image: 'https://quilljs.com/assets/images/icon.png'
},
attributes: {
link: 'https://quilljs.com'
}
}]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should handle concurrent overlapping spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(8, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(11 + 1, { attributes: { bold: null } })
})
let merged = Automerge.merge(s3, s4)
let deltaDoc = automergeTextToDeltaDoc(merged.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf the Grey', attributes: { bold: true } },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should handle debolding spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(8, { attributes: { bold: null } })
doc.text.insertAt(11 + 1, { attributes: { bold: true } })
})
let merged = Automerge.merge(s3, s4)
let deltaDoc = automergeTextToDeltaDoc(merged.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf ', attributes: { bold: true } },
{ insert: 'the' },
{ insert: ' Grey', attributes: { bold: true } },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
// xxx: how would this work for colors?
it('should handle destyling across destyled spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(8, { attributes: { bold: null } })
doc.text.insertAt(11 + 1, { attributes: { bold: true } })
})
let merged = Automerge.merge(s3, s4)
let final = Automerge.change(merged, doc => {
doc.text.insertAt(3 + 1, { attributes: { bold: null } })
doc.text.insertAt(doc.text.length, { attributes: { bold: true } })
})
let deltaDoc = automergeTextToDeltaDoc(final.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gan', attributes: { bold: true } },
{ insert: 'dalf the Grey' },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should apply an insert', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
})
const delta = [
{ retain: 6 },
{ insert: 'reader' },
{ delete: 5 }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
//assert.strictEqual(s2.text.join(''), 'Hello reader')
assert.strictEqual(s2.text.toString(), 'Hello reader')
})
it('should apply an insert with control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
})
const delta = [
{ retain: 6 },
{ insert: 'reader', attributes: { bold: true } },
{ delete: 5 },
{ insert: '!' }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
assert.strictEqual(s2.text.toString(), 'Hello \uFFFCreader\uFFFC!')
assert.deepEqual(s2.text.toSpans(), [
"Hello ",
{ attributes: { bold: true } },
"reader",
{ attributes: { bold: null } },
"!"
])
})
it('should account for control characters in retain/delete lengths', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
doc.text.insertAt(4, { attributes: { color: '#ccc' } })
doc.text.insertAt(10, { attributes: { color: '#f00' } })
})
const delta = [
{ retain: 6 },
{ insert: 'reader', attributes: { bold: true } },
{ delete: 5 },
{ insert: '!' }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
assert.strictEqual(s2.text.toString(), 'Hell\uFFFCo \uFFFCreader\uFFFC\uFFFC!')
assert.deepEqual(s2.text.toSpans(), [
"Hell",
{ attributes: { color: '#ccc'} },
"o ",
{ attributes: { bold: true } },
"reader",
{ attributes: { bold: null } },
{ attributes: { color: '#f00'} },
"!"
])
})
it('should support embeds', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('')
})
let deltaDoc = [{
// An image link
insert: {
image: 'https://quilljs.com/assets/images/icon.png'
},
attributes: {
link: 'https://quilljs.com'
}
}]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(deltaDoc, doc)
})
assert.deepEqual(s2.text.toSpans(), [
{ attributes: { link: 'https://quilljs.com' } },
{ image: 'https://quilljs.com/assets/images/icon.png'},
{ attributes: { link: null } },
])
})
})
})
it('should support unicode when creating text', () => {
s1 = Automerge.from({
text: new Automerge.Text('🐦')
})
assert.strictEqual(s1.text.get(0), '🐦')
})
})