[overleaf-editor-core] Restructure TextOperation hierachy (#16582)

* [overleaf-editor-core] Restructure TextOperation hierachy

Restructures the hierachy of TextOperations to include a superclass
EditOperation. This superclass will later on contain other classes used
for tracked changes and comments.

* [overleaf-editor-core] Update json format of LazyStringFileData

* [history-v1+project-history] Fix TextOperation.fromJSON calls

* [overleaf-editor-core] Change EditOperationBuilder.fromRaw to fromJSON

* [overleaf-editor-core] Update apply and invert functions to accept FileData

* [overleaf-editor-core] Pass missing argument to store method

* [overleaf-editor-core] Remove unused method

* [overleaf-editor-core] User EditOperationTransformer

* [overleaf-editor-core] Clean up JSDoc comments

* [overleaf-editor-core] Add tests for EditOperation

* [overleaf-editor-core] Update JSDoc types

GitOrigin-RevId: 9c22a3a89b8483bdb87b43f329ddbdd887ffed42
This commit is contained in:
Mathias Jakobsen
2024-01-24 09:04:18 +00:00
committed by Copybot
parent fc90db231c
commit 43b2fe4a3a
21 changed files with 457 additions and 179 deletions
@@ -1,17 +1,18 @@
// @ts-check
'use strict'
const { expect } = require('chai')
const ot = require('..')
const EditOperationBuilder = require('../lib/operation/edit_operation_builder')
const File = ot.File
const Operation = ot.Operation
const TextOperation = ot.TextOperation
describe('EditFileOperation', function () {
function edit(pathname, textOperationJsonObject) {
return Operation.editFile(
pathname,
TextOperation.fromJSON(textOperationJsonObject)
EditOperationBuilder.fromJSON({ textOperation: textOperationJsonObject })
)
}
@@ -0,0 +1,55 @@
const { expect } = require('chai')
const EditOperationBuilder = require('../lib/operation/edit_operation_builder')
const TextOperation = require('../lib/operation/text_operation')
const EditOperationTransformer = require('../lib/operation/edit_operation_transformer')
const EditOperation = require('../lib/operation/edit_operation')
const randomTextOperation = require('./support/random_text_operation')
const random = require('./support/random')
describe('EditOperation', function () {
it('Cannot be instantiated', function () {
expect(() => new EditOperation()).to.throw(
'Cannot instantiate abstract class'
)
})
})
describe('EditOperationTransformer', function () {
it('Transforms two TextOperations', function () {
const a = new TextOperation().insert('foo')
const b = new TextOperation().insert('bar')
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
expect(aPrime).to.be.an.instanceof(TextOperation)
expect(bPrime).to.be.an.instanceof(TextOperation)
})
})
describe('EditOperationBuilder', function () {
it('Constructs TextOperation from JSON', function () {
const raw = {
textOperation: [1, 'foo', 3],
}
const op = EditOperationBuilder.fromJSON(raw)
expect(op).to.be.an.instanceof(TextOperation)
expect(op.ops).to.deep.equal([1, 'foo', 3])
})
it('Throws error for unsupported operation', function () {
const raw = {
unsupportedOperation: {
op: 'foo',
},
}
expect(() => EditOperationBuilder.fromJSON(raw)).to.throw(
'Unsupported operation in EditOperationBuilder.fromJSON'
)
})
it('Constructs TextOperation from JSON (randomised)', function () {
const str = random.string(50)
const randomOperation = randomTextOperation(str)
const op = EditOperationBuilder.fromJSON(randomOperation.toJSON())
expect(op).to.be.an.instanceof(TextOperation)
expect(op.equals(randomOperation)).to.be.true
})
})
@@ -21,32 +21,32 @@ describe('LazyStringFileData', function () {
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).to.equal(testHash)
expect(roundTripFileData.getStringLength()).to.equal(0)
expect(roundTripFileData.getTextOperations()).to.have.length(0)
expect(roundTripFileData.getOperations()).to.have.length(0)
fileData.edit(new TextOperation().insert('a'))
expect(fileData.toRaw()).to.eql({
hash: testHash,
stringLength: 1,
textOperations: [['a']],
operations: [{ textOperation: ['a'] }],
})
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).not.to.exist // file has changed
expect(roundTripFileData.getStringLength()).to.equal(1)
expect(roundTripFileData.getTextOperations()).to.have.length(1)
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
expect(roundTripFileData.getOperations()).to.have.length(1)
expect(roundTripFileData.getOperations()[0].ops).to.have.length(1)
fileData.edit(new TextOperation().retain(1).insert('b'))
expect(fileData.toRaw()).to.eql({
hash: testHash,
stringLength: 2,
textOperations: [['a'], [1, 'b']],
operations: [{ textOperation: ['a'] }, { textOperation: [1, 'b'] }],
})
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).not.to.exist // file has changed
expect(roundTripFileData.getStringLength()).to.equal(2)
expect(roundTripFileData.getTextOperations()).to.have.length(2)
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
expect(roundTripFileData.getTextOperations()[1].ops).to.have.length(2)
expect(roundTripFileData.getOperations()).to.have.length(2)
expect(roundTripFileData.getOperations()[0].ops).to.have.length(1)
expect(roundTripFileData.getOperations()[1].ops).to.have.length(2)
})
it('validates operations when edited', function () {
@@ -55,13 +55,13 @@ describe('LazyStringFileData', function () {
expect(fileData.getHash()).equal(testHash)
expect(fileData.getByteLength()).to.equal(0) // approximately
expect(fileData.getStringLength()).to.equal(0)
expect(fileData.getTextOperations()).to.have.length(0)
expect(fileData.getOperations()).to.have.length(0)
fileData.edit(new TextOperation().insert('a'))
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(1) // approximately
expect(fileData.getStringLength()).to.equal(1)
expect(fileData.getTextOperations()).to.have.length(1)
expect(fileData.getOperations()).to.have.length(1)
expect(() => {
fileData.edit(new TextOperation().retain(10))
@@ -69,7 +69,7 @@ describe('LazyStringFileData', function () {
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(1) // approximately
expect(fileData.getStringLength()).to.equal(1)
expect(fileData.getTextOperations()).to.have.length(1)
expect(fileData.getOperations()).to.have.length(1)
})
it('validates string length when edited', function () {
@@ -78,14 +78,14 @@ describe('LazyStringFileData', function () {
expect(fileData.getHash()).equal(testHash)
expect(fileData.getByteLength()).to.equal(0) // approximately
expect(fileData.getStringLength()).to.equal(0)
expect(fileData.getTextOperations()).to.have.length(0)
expect(fileData.getOperations()).to.have.length(0)
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
fileData.edit(new TextOperation().insert(longString))
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
expect(fileData.getStringLength()).to.equal(longString.length)
expect(fileData.getTextOperations()).to.have.length(1)
expect(fileData.getOperations()).to.have.length(1)
expect(() => {
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
@@ -93,6 +93,6 @@ describe('LazyStringFileData', function () {
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
expect(fileData.getStringLength()).to.equal(longString.length)
expect(fileData.getTextOperations()).to.have.length(1)
expect(fileData.getOperations()).to.have.length(1)
})
})
@@ -114,10 +114,10 @@ describe('Operation', function () {
}
// shorthand for creating an edit operation
function edit(pathname, textOperationJsonObject) {
function edit(pathname, textOperationOps) {
return Operation.editFile(
pathname,
TextOperation.fromJSON(textOperationJsonObject)
TextOperation.fromJSON({ textOperation: textOperationOps })
)
}
@@ -633,14 +633,12 @@ describe('Operation', function () {
.expectPrime(1, EditFileOperation)
.expectPrime(0, EditFileOperation)
.tap(function () {
expect(this.primeOperations[1].getTextOperation().toJSON()).to.eql([
1,
'y',
])
expect(this.primeOperations[0].getTextOperation().toJSON()).to.eql([
'x',
1,
])
expect(this.primeOperations[1].getOperation().toJSON()).to.eql({
textOperation: [1, 'y'],
})
expect(this.primeOperations[0].getOperation().toJSON()).to.eql({
textOperation: ['x', 1],
})
})
.swap()
.expectFiles({ foo: 'yx' })
@@ -0,0 +1,31 @@
const TextOperation = require('../../lib/operation/text_operation')
const random = require('./random')
/**
*
* @param {string} str
* @returns {TextOperation}
*/
function randomTextOperation(str) {
const operation = new TextOperation()
let left
while (true) {
left = str.length - operation.baseLength
if (left === 0) break
const r = Math.random()
const l = 1 + random.int(Math.min(left - 1, 20))
if (r < 0.2) {
operation.insert(random.string(l))
} else if (r < 0.4) {
operation.remove(l)
} else {
operation.retain(l)
}
}
if (Math.random() < 0.3) {
operation.insert(1 + random.string(10))
}
return operation
}
module.exports = randomTextOperation
@@ -7,31 +7,11 @@
const { expect } = require('chai')
const random = require('./support/random')
const randomOperation = require('./support/random_text_operation')
const ot = require('..')
const TextOperation = ot.TextOperation
function randomOperation(str) {
const operation = new TextOperation()
let left
while (true) {
left = str.length - operation.baseLength
if (left === 0) break
const r = Math.random()
const l = 1 + random.int(Math.min(left - 1, 20))
if (r < 0.2) {
operation.insert(random.string(l))
} else if (r < 0.4) {
operation.remove(l)
} else {
operation.retain(l)
}
}
if (Math.random() < 0.3) {
operation.insert(1 + random.string(10))
}
return operation
}
const StringFileData = require('../lib/file_data/string_file_data')
describe('TextOperation', function () {
const numTrials = 500
@@ -134,7 +114,7 @@ describe('TextOperation', function () {
it('converts from JSON', function () {
const ops = [2, -1, -1, 'cde']
const o = TextOperation.fromJSON(ops)
const o = TextOperation.fromJSON({ textOperation: ops })
expect(o.ops.length).to.equal(3)
expect(o.baseLength).to.equal(4)
expect(o.targetLength).to.equal(5)
@@ -143,7 +123,7 @@ describe('TextOperation', function () {
const ops2 = ops.slice(0)
fn(ops2)
expect(() => {
TextOperation.fromJSON(ops2)
TextOperation.fromJSON({ textOperations: ops2 })
}).to.throw
}
@@ -161,7 +141,10 @@ describe('TextOperation', function () {
const str = random.string(50)
const o = randomOperation(str)
expect(str.length).to.equal(o.baseLength)
expect(o.apply(str).length).to.equal(o.targetLength)
const file = new StringFileData(str)
o.apply(file)
const result = file.getContent()
expect(result.length).to.equal(o.targetLength)
})
)
@@ -170,10 +153,14 @@ describe('TextOperation', function () {
random.test(numTrials, () => {
const str = random.string(50)
const o = randomOperation(str)
const p = o.invert(str)
const p = o.invert(new StringFileData(str))
expect(o.baseLength).to.equal(p.targetLength)
expect(o.targetLength).to.equal(p.baseLength)
expect(p.apply(o.apply(str))).to.equal(str)
const file = new StringFileData(str)
o.apply(file)
p.apply(file)
const result = file.getContent()
expect(result).to.equal(str)
})
)
@@ -193,14 +180,18 @@ describe('TextOperation', function () {
// invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
const str = random.string(20)
const a = randomOperation(str)
const afterA = a.apply(str)
const file = new StringFileData(str)
a.apply(file)
const afterA = file.getContent()
expect(afterA.length).to.equal(a.targetLength)
const b = randomOperation(afterA)
const afterB = b.apply(afterA)
b.apply(file)
const afterB = file.getContent()
expect(afterB.length).to.equal(b.targetLength)
const ab = a.compose(b)
expect(ab.targetLength).to.equal(b.targetLength)
const afterAB = ab.apply(str)
ab.apply(new StringFileData(str))
const afterAB = file.getContent()
expect(afterAB).to.equal(afterB)
})
)
@@ -218,20 +209,22 @@ describe('TextOperation', function () {
const bPrime = primes[1]
const abPrime = a.compose(bPrime)
const baPrime = b.compose(aPrime)
const afterAbPrime = abPrime.apply(str)
const afterBaPrime = baPrime.apply(str)
const abFile = new StringFileData(str)
const baFile = new StringFileData(str)
abPrime.apply(abFile)
baPrime.apply(baFile)
expect(abPrime.equals(baPrime)).to.be.true
expect(afterAbPrime).to.equal(afterBaPrime)
expect(abFile.getContent()).to.equal(baFile.getContent())
})
)
it('throws when invalid operations are applied', function () {
const operation = new TextOperation().retain(1)
expect(() => {
operation.apply('')
operation.apply(new StringFileData(''))
}).to.throw(TextOperation.ApplyError)
expect(() => {
operation.apply(' ')
operation.apply(new StringFileData(' '))
}).not.to.throw
})
@@ -250,7 +243,7 @@ describe('TextOperation', function () {
const operation = new TextOperation()
const str = '𝌆\n'
expect(() => {
operation.apply(str)
operation.apply(new StringFileData(str))
}).to.throw(
TextOperation.UnprocessableError,
/string contains non BMP characters/
@@ -260,7 +253,7 @@ describe('TextOperation', function () {
it('throws at from JSON when it contains non BMP chars', function () {
const operation = ['𝌆\n']
expect(() => {
TextOperation.fromJSON(operation)
TextOperation.fromJSON({ textOperation: operation })
}).to.throw(
TextOperation.UnprocessableError,
/inserted text contains non BMP characters/