mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 14:43:46 +01:00
Avoid duplicating a math-closing dollar sign (#11227)
GitOrigin-RevId: ef2ef77e26df59d1af3df6dc664e284d3c70102d
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const Operation = core.Operation
|
||||
|
||||
describe('Change', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from operations', function () {
|
||||
const blobHashes = new Set()
|
||||
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with content doesn't have a hash.
|
||||
change.pushOperation(Operation.addFile('a.txt', File.fromString('a')))
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with hash should give us a hash.
|
||||
change.pushOperation(
|
||||
Operation.addFile('b.txt', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
)
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(1)
|
||||
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
describe('canBeComposedWith', function () {
|
||||
it('on the same file', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('on different files', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('bar.tex', ['y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with a different type of opperation', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = Operation.addFile(
|
||||
'bar.tex',
|
||||
File.fromString('')
|
||||
)
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with incompatible lengths', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [2, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('canBeComposedWithForUndo', function () {
|
||||
it('can', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('cannot', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', ['y', 1, 'z'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('compose', function () {
|
||||
it('composes text operations', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
const composedFileOperation =
|
||||
editFileOperation1.compose(editFileOperation2)
|
||||
const expectedComposedFileOperation = edit('foo.tex', ['xy'])
|
||||
expect(composedFileOperation).to.deep.equal(expectedComposedFileOperation)
|
||||
|
||||
// check that the original operation wasn't modified
|
||||
expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x']))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const FakeBlobStore = require('./support/fake_blob_store')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
|
||||
describe('File', function () {
|
||||
it('can have attached metadata', function () {
|
||||
// no metadata
|
||||
let file = File.fromString('foo')
|
||||
expect(file.getMetadata()).to.eql({})
|
||||
|
||||
// metadata passed in at construction time
|
||||
file = File.fromString('foo', { main: true })
|
||||
expect(file.getMetadata()).to.eql({ main: true })
|
||||
|
||||
// metadata set at runtime
|
||||
file.setMetadata({ main: false })
|
||||
expect(file.getMetadata()).to.eql({ main: false })
|
||||
})
|
||||
|
||||
describe('toRaw', function () {
|
||||
it('returns non-empty metadata', function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
expect(file.toRaw()).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata: metadata,
|
||||
})
|
||||
|
||||
delete file.getMetadata().main
|
||||
expect(file.toRaw()).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const raw = file.toRaw()
|
||||
const fileMetadata = file.getMetadata()
|
||||
const rawMetadata = raw.metadata
|
||||
expect(rawMetadata).not.to.equal(fileMetadata)
|
||||
expect(rawMetadata).to.deep.equal(fileMetadata)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store', function () {
|
||||
it('does not return empty metadata', function () {
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
expect(raw).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns non-empty metadata', function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
expect(raw).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata: metadata,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
raw.metadata.externalFile.id = 456
|
||||
expect(file.getMetadata().externalFile.id).to.equal(123)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with string data', function () {
|
||||
it('can be created from a string', function () {
|
||||
const file = File.fromString('foo')
|
||||
expect(file.getContent()).to.equal('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with hollow string data', function () {
|
||||
it('can be cloned', function () {
|
||||
const file = File.createHollow(null, 0)
|
||||
expect(file.getStringLength()).to.equal(0)
|
||||
const clone = file.clone()
|
||||
expect(clone.getStringLength()).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const BPromise = require('bluebird')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const FileMap = ot.FileMap
|
||||
|
||||
describe('FileMap', function () {
|
||||
function makeTestFile(pathname) {
|
||||
return File.fromString(pathname)
|
||||
}
|
||||
|
||||
function makeTestFiles(pathnames) {
|
||||
return _.zipObject(pathnames, _.map(pathnames, makeTestFile))
|
||||
}
|
||||
|
||||
function makeFileMap(pathnames) {
|
||||
return new FileMap(makeTestFiles(pathnames))
|
||||
}
|
||||
|
||||
it('allows construction with a single file', function () {
|
||||
makeFileMap(['a'])
|
||||
})
|
||||
|
||||
it('allows folders to differ by case', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a/b', 'A/c'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'A/b/d'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'a/B/d'])
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('does not allow conflicting paths on construct', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a', 'a/b'])
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('detects conflicting paths with characters that sort before /', function () {
|
||||
const fileMap = makeFileMap(['a', 'a!'])
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
})
|
||||
|
||||
it('detects conflicting paths', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/c/d')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/d')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('d/b/c')).to.be.falsy
|
||||
})
|
||||
|
||||
it('allows paths that differ by case', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/C')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/B')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/B')).to.be.falsy
|
||||
})
|
||||
|
||||
it('does not add a file with a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
const file = makeTestFile('a/b/c')
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('a/b/c', file)
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not move a file to a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('errors when trying to move a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.moveFile('b', 'a')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('moves a file over an empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('a').getContent()).to.equal('a/b')
|
||||
})
|
||||
|
||||
it('does not move a file over a non-empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on add', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.addFile('A', makeTestFile('A'))
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('A')
|
||||
})
|
||||
|
||||
it('changes case on move', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.moveFile('a', 'A')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on move', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.moveFile('a', 'B')
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
expect(fileMap.files.B).to.exist
|
||||
expect(fileMap.getFile('B').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not find pathname that differs by case', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A')).not.to.exist
|
||||
expect(fileMap.getFile('b')).not.to.exist
|
||||
})
|
||||
|
||||
it('does not allow non-safe pathnames', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['c*'])
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
const fileMap = makeFileMap([])
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('c*', makeTestFile('c:'))
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
fileMap.addFile('a', makeTestFile('a'))
|
||||
expect(() => {
|
||||
fileMap.moveFile('a', 'c*')
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('hasOwnProperty', makeTestFile('hasOwnProperty'))
|
||||
fileMap.addFile('anotherFile', makeTestFile('anotherFile'))
|
||||
}).to.throw()
|
||||
})
|
||||
|
||||
it('removes a file', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.removeFile('a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
})
|
||||
|
||||
it('errors when trying to remove a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.removeFile('b')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('has mapAsync', function () {
|
||||
const concurrency = 1
|
||||
return BPromise.map(
|
||||
[
|
||||
[[], {}],
|
||||
[['a'], { a: 'a-a' }], // the test is to map to "content-pathname"
|
||||
[['a', 'b'], { a: 'a-a', b: 'b-b' }],
|
||||
],
|
||||
test => {
|
||||
const input = test[0]
|
||||
const expectedOutput = test[1]
|
||||
const fileMap = makeFileMap(input)
|
||||
return fileMap
|
||||
.mapAsync((file, pathname) => {
|
||||
return file.getContent() + '-' + pathname
|
||||
}, concurrency)
|
||||
.then(result => {
|
||||
expect(result).to.deep.equal(expectedOutput)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const History = core.History
|
||||
const Operation = core.Operation
|
||||
const Snapshot = core.Snapshot
|
||||
|
||||
describe('History', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from snapshot and changes', function () {
|
||||
const history = new History(new Snapshot(), [])
|
||||
|
||||
const blobHashes = new Set()
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash to the snapshot.
|
||||
history.getSnapshot().addFile('foo', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
|
||||
// Add a file with a hash to the changes.
|
||||
const testHash = 'a'.repeat(40)
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
change.pushOperation(Operation.addFile('bar', File.fromHash(testHash)))
|
||||
|
||||
history.pushChanges([change])
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([
|
||||
File.EMPTY_FILE_HASH,
|
||||
testHash,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const HollowStringFileData = require('../lib/file_data/hollow_string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('HollowStringFileData', function () {
|
||||
it('validates string length when edited', function () {
|
||||
const maxLength = TextOperation.MAX_STRING_LENGTH
|
||||
const fileData = new HollowStringFileData(maxLength)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(maxLength).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
fileData.edit(new TextOperation().retain(maxLength - 1).remove(1))
|
||||
expect(fileData.getStringLength()).to.equal(maxLength - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const Label = ot.Label
|
||||
|
||||
describe('Label', function () {
|
||||
it('can be created by an anonymous author', function () {
|
||||
const label = Label.fromRaw({
|
||||
text: 'test',
|
||||
authorId: null,
|
||||
timestamp: '2016-01-01T00:00:00Z',
|
||||
version: 123,
|
||||
})
|
||||
expect(label.getAuthorId()).to.be.null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const TextOperation = ot.TextOperation
|
||||
const LazyStringFileData = require('../lib/file_data/lazy_string_file_data')
|
||||
|
||||
describe('LazyStringFileData', function () {
|
||||
it('uses raw text operations for toRaw and fromRaw', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
let roundTripFileData
|
||||
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 0,
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).to.equal(testHash)
|
||||
expect(roundTripFileData.getStringLength()).to.equal(0)
|
||||
expect(roundTripFileData.getTextOperations()).to.have.length(0)
|
||||
|
||||
fileData.edit(new TextOperation().insert('a'))
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 1,
|
||||
textOperations: [['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)
|
||||
|
||||
fileData.edit(new TextOperation().retain(1).insert('b'))
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 2,
|
||||
textOperations: [['a'], [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)
|
||||
})
|
||||
|
||||
it('validates operations when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
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)
|
||||
|
||||
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.edit(new TextOperation().retain(10))
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
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)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
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)
|
||||
|
||||
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.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const MoveFileOperation = ot.MoveFileOperation
|
||||
const Snapshot = ot.Snapshot
|
||||
|
||||
describe('MoveFileOperation', function () {
|
||||
function makeEmptySnapshot() {
|
||||
return new Snapshot()
|
||||
}
|
||||
|
||||
function makeOneFileSnapshot() {
|
||||
const snapshot = makeEmptySnapshot()
|
||||
snapshot.addFile('foo', File.fromString('test: foo'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function makeTwoFileSnapshot() {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
snapshot.addFile('bar', File.fromString('test: bar'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
it('moves a file over another', function () {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'bar')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(1)
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: foo')
|
||||
})
|
||||
|
||||
it('moves a file to another pathname', function () {
|
||||
const snapshot = makeTwoFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'a')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(2)
|
||||
expect(snapshot.getFile('a').getContent()).to.equal('test: foo')
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: bar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,746 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const AddFileOperation = ot.AddFileOperation
|
||||
const MoveFileOperation = ot.MoveFileOperation
|
||||
const EditFileOperation = ot.EditFileOperation
|
||||
const NoOperation = ot.NoOperation
|
||||
const Operation = ot.Operation
|
||||
const TextOperation = ot.TextOperation
|
||||
const Snapshot = ot.Snapshot
|
||||
|
||||
describe('Operation', function () {
|
||||
function makeEmptySnapshot() {
|
||||
return new Snapshot()
|
||||
}
|
||||
|
||||
function makeOneFileSnapshot() {
|
||||
const snapshot = makeEmptySnapshot()
|
||||
snapshot.addFile('foo', File.fromString(''))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function makeTwoFileSnapshot() {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
snapshot.addFile('bar', File.fromString('a'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function addFile(pathname, content) {
|
||||
return new AddFileOperation(pathname, File.fromString(content))
|
||||
}
|
||||
|
||||
function roundTripOperation(operation) {
|
||||
return Operation.fromRaw(operation.toRaw())
|
||||
}
|
||||
|
||||
function deepCopySnapshot(snapshot) {
|
||||
return Snapshot.fromRaw(snapshot.toRaw())
|
||||
}
|
||||
|
||||
function runConcurrently(operation0, operation1, snapshot) {
|
||||
const operations = [
|
||||
// make sure they survive serialization
|
||||
roundTripOperation(operation0),
|
||||
roundTripOperation(operation1),
|
||||
]
|
||||
const primeOperations = Operation.transform(operation0, operation1)
|
||||
const originalSnapshot = snapshot || makeEmptySnapshot()
|
||||
const snapshotA = deepCopySnapshot(originalSnapshot)
|
||||
const snapshotB = deepCopySnapshot(originalSnapshot)
|
||||
|
||||
operations[0].applyTo(snapshotA)
|
||||
operations[1].applyTo(snapshotB)
|
||||
|
||||
primeOperations[0].applyTo(snapshotB)
|
||||
primeOperations[1].applyTo(snapshotA)
|
||||
expect(snapshotA).to.eql(snapshotB)
|
||||
|
||||
return {
|
||||
snapshot: snapshotA,
|
||||
operations: operations,
|
||||
primeOperations: primeOperations,
|
||||
|
||||
log() {
|
||||
console.log(this)
|
||||
return this
|
||||
},
|
||||
|
||||
expectNoTransform() {
|
||||
expect(this.operations).to.eql(this.primeOperations)
|
||||
return this
|
||||
},
|
||||
|
||||
swap() {
|
||||
return runConcurrently(operation1, operation0, originalSnapshot)
|
||||
},
|
||||
|
||||
expectFiles(files) {
|
||||
this.expectedFiles = files
|
||||
expect(this.snapshot.countFiles()).to.equal(_.size(files))
|
||||
_.forOwn(files, (expectedFile, pathname) => {
|
||||
if (_.isString(expectedFile)) {
|
||||
expectedFile = { content: expectedFile, metadata: {} }
|
||||
}
|
||||
const file = this.snapshot.getFile(pathname)
|
||||
expect(file.getContent()).to.equal(expectedFile.content)
|
||||
expect(file.getMetadata()).to.eql(expectedFile.metadata)
|
||||
})
|
||||
return this
|
||||
},
|
||||
|
||||
expectSymmetry() {
|
||||
if (!this.expectedFiles) {
|
||||
throw new Error('must call expectFiles before expectSymmetry')
|
||||
}
|
||||
this.swap().expectFiles(this.expectedFiles)
|
||||
return this
|
||||
},
|
||||
|
||||
expectPrime(index, klass) {
|
||||
expect(this.primeOperations[index]).to.be.an.instanceof(klass)
|
||||
return this
|
||||
},
|
||||
|
||||
tap(fn) {
|
||||
fn.call(this)
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// shorthand for creating an edit operation
|
||||
function edit(pathname, textOperationJsonObject) {
|
||||
return Operation.editFile(
|
||||
pathname,
|
||||
TextOperation.fromJSON(textOperationJsonObject)
|
||||
)
|
||||
}
|
||||
|
||||
it('transforms AddFile-AddFile with different names', function () {
|
||||
runConcurrently(addFile('foo', ''), addFile('bar', 'a'))
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', foo: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-AddFile with same name', function () {
|
||||
// the second file 'wins'
|
||||
runConcurrently(addFile('foo', ''), addFile('foo', 'a'))
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the first add was committed first, the second add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the second add was committed first, the first add becomes a no-op
|
||||
.expectPrime(0, NoOperation)
|
||||
.swap()
|
||||
.expectFiles({ foo: '' })
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', baz: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with move from new file', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move appears in the history
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with move to new file', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('baz', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move becomes a delete
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-RemoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-RemoveFile that removes added file', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move gets dropped
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', foo: 'x' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-EditFile when new file is edited', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the edit was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the edit gets dropped
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
addFile('bar', 'a'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata }, bar: 'a' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-SetFileMetadata with same name', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
addFile('foo', 'x'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeEmptySnapshot()
|
||||
)
|
||||
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'bat'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bat: 'a', baz: '' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile same move foo->foo, foo->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, foo->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('bar', 'bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'a', foo: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile same move foo->bar, foo->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile opposite foo->bar, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a', foo: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile diverge foo->bar, foo->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: '' })
|
||||
// if foo->bar was committed first, the second move becomes bar->baz
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
// if foo->baz was committed first, the second move becomes a no-op
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
expect(this.primeOperations[1].getNewPathname()).to.equal('baz')
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ bar: '' })
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile transitive foo->baz, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile transitive foo->bar, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile converge foo->baz, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
// if foo->baz was committed first, we just apply the move
|
||||
expect(this.primeOperations[1]).to.eql(this.operations[1])
|
||||
|
||||
// if bar->baz was committed first, the other move becomes a remove
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ baz: '' })
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no-op foo->foo, foo->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile same move foo->, foo->', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no conflict foo->, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no conflict foo->foo, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile transitive foo->, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile transitive foo->bar, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
edit('foo', ['x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a', foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with edit on pathname', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
edit('foo', ['x']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'x' })
|
||||
.expectPrime(1, EditFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
expect(this.primeOperations[0].getNewPathname()).to.equal('bar')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with edit on new pathname', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
edit('foo', ['x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[0]).to.eql(this.operations[0])
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with no-op move', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
edit('foo', ['x']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: { content: 'a', metadata: testMetadata }, baz: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with set on pathname', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata w/ set on new pathname', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
// move wins
|
||||
.expectFiles({ bar: { content: '', metadata: {} } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with no-op move', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
edit('bar', [1, 'x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'ax', foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-EditFile on same file', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
edit('foo', ['y']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'xy' })
|
||||
.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,
|
||||
])
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ foo: 'yx' })
|
||||
})
|
||||
|
||||
it('transforms EditFile-RemoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-RemoveFile on same file', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({
|
||||
foo: { content: 'x', metadata: {} },
|
||||
bar: { content: 'a', metadata: testMetadata },
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-SetFileMetadata on same file', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-SetFileMetadata w/ no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', { baz: 1 }),
|
||||
Operation.setFileMetadata('bar', { baz: 2 }),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({
|
||||
foo: { content: '', metadata: { baz: 1 } },
|
||||
bar: { content: 'a', metadata: { baz: 2 } },
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-SetFileMetadata on same file', function () {
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', { baz: 1 }),
|
||||
Operation.setFileMetadata('foo', { baz: 2 }),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
// second op wins
|
||||
.expectFiles({ foo: { content: '', metadata: { baz: 2 } } })
|
||||
.swap()
|
||||
// first op wins
|
||||
.expectFiles({ foo: { content: '', metadata: { baz: 1 } } })
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-RemoveFile with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-RemoveFile on same file', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectSymmetry()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const safePathname = ot.safePathname
|
||||
|
||||
describe('safePathname', function () {
|
||||
function expectClean(input, output) {
|
||||
// check expected output and also idempotency
|
||||
const cleanedInput = safePathname.clean(input)
|
||||
expect(cleanedInput).to.equal(output)
|
||||
expect(safePathname.clean(cleanedInput)).to.equal(cleanedInput)
|
||||
expect(safePathname.isClean(cleanedInput)).to.be.true
|
||||
}
|
||||
|
||||
it('cleans pathnames', function () {
|
||||
// preserve valid pathnames
|
||||
expectClean('llama.jpg', 'llama.jpg')
|
||||
expectClean('DSC4056.JPG', 'DSC4056.JPG')
|
||||
|
||||
// detects unclean pathnames
|
||||
expect(safePathname.isClean('rm -rf /')).to.be.falsy
|
||||
|
||||
// replace invalid characters with underscores
|
||||
expectClean('test-s*\u0001\u0002m\u0007st\u0008.jpg', 'test-s___m_st_.jpg')
|
||||
|
||||
// keep slashes, normalize paths, replace ..
|
||||
expectClean('./foo', 'foo')
|
||||
expectClean('../foo', '__/foo')
|
||||
expectClean('foo/./bar', 'foo/bar')
|
||||
expectClean('foo/../bar', 'bar')
|
||||
expectClean('../../tricky/foo.bar', '__/__/tricky/foo.bar')
|
||||
expectClean('foo/../../tricky/foo.bar', '__/tricky/foo.bar')
|
||||
expectClean('foo/bar/../../tricky/foo.bar', 'tricky/foo.bar')
|
||||
expectClean('foo/bar/baz/../../tricky/foo.bar', 'foo/tricky/foo.bar')
|
||||
|
||||
// remove illegal chars even when there is no extension
|
||||
expectClean('**foo', '__foo')
|
||||
|
||||
// remove windows file paths
|
||||
expectClean('c:\\temp\\foo.txt', 'c:/temp/foo.txt')
|
||||
|
||||
// do not allow a leading slash (relative paths only)
|
||||
expectClean('/foo', '_/foo')
|
||||
expectClean('//foo', '_/foo')
|
||||
|
||||
// do not allow multiple leading slashes
|
||||
expectClean('//foo', '_/foo')
|
||||
|
||||
// do not allow a trailing slash
|
||||
expectClean('/', '_')
|
||||
expectClean('foo/', 'foo')
|
||||
expectClean('foo.tex/', 'foo.tex')
|
||||
|
||||
// do not allow multiple trailing slashes
|
||||
expectClean('//', '_')
|
||||
expectClean('///', '_')
|
||||
expectClean('foo//', 'foo')
|
||||
|
||||
// file and folder names that consist of . and .. are not OK
|
||||
expectClean('.', '_')
|
||||
expectClean('..', '__')
|
||||
// we will allow name with more dots e.g. ... and ....
|
||||
expectClean('...', '...')
|
||||
expectClean('....', '....')
|
||||
expectClean('foo/...', 'foo/...')
|
||||
expectClean('foo/....', 'foo/....')
|
||||
expectClean('foo/.../bar', 'foo/.../bar')
|
||||
expectClean('foo/..../bar', 'foo/..../bar')
|
||||
|
||||
// leading dots are OK
|
||||
expectClean('._', '._')
|
||||
expectClean('.gitignore', '.gitignore')
|
||||
|
||||
// trailing dots are not OK on Windows but we allow them
|
||||
expectClean('_.', '_.')
|
||||
expectClean('foo/_.', 'foo/_.')
|
||||
expectClean('foo/_./bar', 'foo/_./bar')
|
||||
expectClean('foo/_../bar', 'foo/_../bar')
|
||||
|
||||
// spaces are allowed
|
||||
expectClean('a b.png', 'a b.png')
|
||||
|
||||
// leading and trailing spaces are not OK
|
||||
expectClean(' foo', 'foo')
|
||||
expectClean(' foo', 'foo')
|
||||
expectClean('foo ', 'foo')
|
||||
expectClean('foo ', 'foo')
|
||||
|
||||
// reserved file names on Windows should not be OK, but we already have
|
||||
// some in the old system, so have to allow them for now
|
||||
expectClean('AUX', 'AUX')
|
||||
expectClean('foo/AUX', 'foo/AUX')
|
||||
expectClean('AUX/foo', 'AUX/foo')
|
||||
|
||||
// multiple dots are OK
|
||||
expectClean('a.b.png', 'a.b.png')
|
||||
expectClean('a.code.tex', 'a.code.tex')
|
||||
|
||||
// there's no particular reason to allow multiple slashes; sometimes people
|
||||
// seem to rename files to URLs (https://domain/path) in an attempt to
|
||||
// upload a file, and this results in an empty directory name
|
||||
expectClean('foo//bar.png', 'foo/bar.png')
|
||||
expectClean('foo///bar.png', 'foo/bar.png')
|
||||
|
||||
// Check javascript property handling
|
||||
expectClean('foo/prototype', 'foo/prototype') // OK as part of a pathname
|
||||
expectClean('prototype/test.txt', 'prototype/test.txt')
|
||||
expectClean('prototype', '@prototype') // not OK as whole pathname
|
||||
expectClean('hasOwnProperty', '@hasOwnProperty')
|
||||
expectClean('**proto**', '@__proto__')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
File,
|
||||
Snapshot,
|
||||
TextOperation,
|
||||
Change,
|
||||
EditFileOperation,
|
||||
} = require('..')
|
||||
|
||||
describe('Snapshot', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from files', function () {
|
||||
const snapshot = new Snapshot()
|
||||
|
||||
const blobHashes = new Set()
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file without a hash.
|
||||
snapshot.addFile('foo', File.fromString(''))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash.
|
||||
snapshot.addFile('bar', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
})
|
||||
})
|
||||
|
||||
describe('editFile', function () {
|
||||
let snapshot
|
||||
let operation
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('hello.txt', File.fromString('hello'))
|
||||
operation = new TextOperation()
|
||||
operation.retain(5)
|
||||
operation.insert(' world!')
|
||||
})
|
||||
|
||||
it('applies text operations to the file', function () {
|
||||
snapshot.editFile('hello.txt', operation)
|
||||
const file = snapshot.getFile('hello.txt')
|
||||
expect(file.getContent()).to.equal('hello world!')
|
||||
})
|
||||
|
||||
it('rejects text operations for nonexistent file', function () {
|
||||
expect(() => {
|
||||
snapshot.editFile('does-not-exist.txt', operation)
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAll', function () {
|
||||
let snapshot
|
||||
let change
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('empty.txt', File.fromString(''))
|
||||
const badTextOp = new TextOperation()
|
||||
badTextOp.insert('FAIL!')
|
||||
const goodTextOp = new TextOperation()
|
||||
goodTextOp.insert('SUCCESS!')
|
||||
change = new Change(
|
||||
[
|
||||
new EditFileOperation('missing.txt', badTextOp),
|
||||
new EditFileOperation('empty.txt', goodTextOp),
|
||||
],
|
||||
new Date()
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores recoverable errors', function () {
|
||||
snapshot.applyAll([change])
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('SUCCESS!')
|
||||
})
|
||||
|
||||
it('stops on recoverable errors in strict mode', function () {
|
||||
expect(() => {
|
||||
snapshot.applyAll([change], { strict: true })
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('StringFileData', function () {
|
||||
it('throws when it contains non BMP chars', function () {
|
||||
const content = '𝌆𝌆𝌆'
|
||||
const fileData = new StringFileData(content)
|
||||
const operation = new TextOperation()
|
||||
operation.insert('aa')
|
||||
expect(() => {
|
||||
fileData.edit(operation)
|
||||
}).to.throw(TextOperation.ApplyError, /string contains non BMP characters/)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
|
||||
const fileData = new StringFileData(longString)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
fileData.edit(new TextOperation().retain(longString.length - 1).remove(1))
|
||||
expect(fileData.getByteLength()).to.equal(longString.length - 1)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @typedef {import("../..").Blob } Blob
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("bluebird")<T>} BPromise
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fake blob store for tests
|
||||
*/
|
||||
class FakeBlobStore {
|
||||
/**
|
||||
* Get a string from the blob store
|
||||
*
|
||||
* @param {string} hash
|
||||
* @return {BPromise<string>}
|
||||
*/
|
||||
getString(hash) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a string in the blob store
|
||||
*
|
||||
* @param {string} content
|
||||
* @return {BPromise<Blob>}
|
||||
*/
|
||||
putString(content) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FakeBlobStore
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Randomised testing helpers from OT.js:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/helpers.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
function randomInt(n) {
|
||||
return Math.floor(Math.random() * n)
|
||||
}
|
||||
|
||||
function randomString(n) {
|
||||
let str = ''
|
||||
while (n--) {
|
||||
if (Math.random() < 0.15) {
|
||||
str += '\n'
|
||||
} else {
|
||||
const chr = randomInt(26) + 97
|
||||
str += String.fromCharCode(chr)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
function randomElement(arr) {
|
||||
return arr[randomInt(arr.length)]
|
||||
}
|
||||
|
||||
function randomTest(numTrials, test) {
|
||||
return function () {
|
||||
while (numTrials--) test()
|
||||
}
|
||||
}
|
||||
|
||||
exports.int = randomInt
|
||||
exports.string = randomString
|
||||
exports.element = randomElement
|
||||
exports.test = randomTest
|
||||
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// These tests are based on the OT.js tests:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const random = require('./support/random')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
describe('TextOperation', function () {
|
||||
const numTrials = 500
|
||||
|
||||
it('tracks base and target lengths', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.baseLength).to.equal(0)
|
||||
expect(o.targetLength).to.equal(0)
|
||||
o.retain(5)
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
o.insert('abc')
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(8)
|
||||
o.retain(2)
|
||||
expect(o.baseLength).to.equal(7)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
o.remove(2)
|
||||
expect(o.baseLength).to.equal(9)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
})
|
||||
|
||||
it('supports chaining', function () {
|
||||
const o = new TextOperation()
|
||||
.retain(5)
|
||||
.retain(0)
|
||||
.insert('lorem')
|
||||
.insert('')
|
||||
.remove('abc')
|
||||
.remove(3)
|
||||
.remove(0)
|
||||
.remove('')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
})
|
||||
|
||||
it('ignores empty operations', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(0)
|
||||
o.insert('')
|
||||
o.remove('')
|
||||
expect(o.ops.length).to.equal(0)
|
||||
})
|
||||
|
||||
it('checks for equality', function () {
|
||||
const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
|
||||
const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
op1.remove(1)
|
||||
op2.retain(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('merges ops', function () {
|
||||
function last(arr) {
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
const o = new TextOperation()
|
||||
expect(o.ops.length).to.equal(0)
|
||||
o.retain(2)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops)).to.equal(2)
|
||||
o.retain(3)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops)).to.equal(5)
|
||||
o.insert('abc')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops)).to.equal('abc')
|
||||
o.insert('xyz')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops)).to.equal('abcxyz')
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops)).to.equal(-1)
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops)).to.equal(-2)
|
||||
})
|
||||
|
||||
it('checks for no-ops', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(5)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(3)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.insert('lorem')
|
||||
expect(o.isNoop()).to.be.false
|
||||
})
|
||||
|
||||
it('converts to string', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(2)
|
||||
o.insert('lorem')
|
||||
o.remove('ipsum')
|
||||
o.retain(5)
|
||||
expect(o.toString()).to.equal(
|
||||
"retain 2, insert 'lorem', remove 5, retain 5"
|
||||
)
|
||||
})
|
||||
|
||||
it('converts from JSON', function () {
|
||||
const ops = [2, -1, -1, 'cde']
|
||||
const o = TextOperation.fromJSON(ops)
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(o.baseLength).to.equal(4)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
|
||||
function assertIncorrectAfter(fn) {
|
||||
const ops2 = ops.slice(0)
|
||||
fn(ops2)
|
||||
expect(() => {
|
||||
TextOperation.fromJSON(ops2)
|
||||
}).to.throw
|
||||
}
|
||||
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push({ insert: 'x' })
|
||||
})
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push(null)
|
||||
})
|
||||
})
|
||||
|
||||
it(
|
||||
'applies (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
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)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'inverts (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const str = random.string(50)
|
||||
const o = randomOperation(str)
|
||||
const p = o.invert(str)
|
||||
expect(o.baseLength).to.equal(p.targetLength)
|
||||
expect(o.targetLength).to.equal(p.baseLength)
|
||||
expect(p.apply(o.apply(str))).to.equal(str)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'converts to/from JSON (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const doc = random.string(50)
|
||||
const operation = randomOperation(doc)
|
||||
const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
|
||||
expect(operation.equals(roundTripOperation)).to.be.true
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'composes (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// 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)
|
||||
expect(afterA.length).to.equal(a.targetLength)
|
||||
const b = randomOperation(afterA)
|
||||
const afterB = b.apply(afterA)
|
||||
expect(afterB.length).to.equal(b.targetLength)
|
||||
const ab = a.compose(b)
|
||||
expect(ab.targetLength).to.equal(b.targetLength)
|
||||
const afterAB = ab.apply(str)
|
||||
expect(afterAB).to.equal(afterB)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'transforms (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// invariant: compose(a, b') = compose(b, a')
|
||||
// where (a', b') = transform(a, b)
|
||||
const str = random.string(20)
|
||||
const a = randomOperation(str)
|
||||
const b = randomOperation(str)
|
||||
const primes = TextOperation.transform(a, b)
|
||||
const aPrime = primes[0]
|
||||
const bPrime = primes[1]
|
||||
const abPrime = a.compose(bPrime)
|
||||
const baPrime = b.compose(aPrime)
|
||||
const afterAbPrime = abPrime.apply(str)
|
||||
const afterBaPrime = baPrime.apply(str)
|
||||
expect(abPrime.equals(baPrime)).to.be.true
|
||||
expect(afterAbPrime).to.equal(afterBaPrime)
|
||||
})
|
||||
)
|
||||
|
||||
it('throws when invalid operations are applied', function () {
|
||||
const operation = new TextOperation().retain(1)
|
||||
expect(() => {
|
||||
operation.apply('')
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
expect(() => {
|
||||
operation.apply(' ')
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('throws when insert text contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.insert(str)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when base string contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.apply(str)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/string contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws at from JSON when it contains non BMP chars', function () {
|
||||
const operation = ['𝌆\n']
|
||||
expect(() => {
|
||||
TextOperation.fromJSON(operation)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user