'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() }) })