diff --git a/services/track-changes/Gruntfile.coffee b/services/track-changes/Gruntfile.coffee index 2488d2e8f3..ccd1654e2d 100644 --- a/services/track-changes/Gruntfile.coffee +++ b/services/track-changes/Gruntfile.coffee @@ -55,12 +55,12 @@ module.exports = (grunt) -> mochaTest: unit: - src: ['test/unit/js/**/*.js'] + src: ["test/unit/js/#{grunt.option("feature") or "**"}/*.js"] options: reporter: grunt.option('reporter') or 'spec' grep: grunt.option("grep") acceptance: - src: ['test/acceptance/js/**/*.js'] + src: ["test/acceptance/js/**/*.js"] options: reporter: grunt.option('reporter') or 'spec' grep: grunt.option("grep") diff --git a/services/track-changes/app/coffee/DiffGenerator.coffee b/services/track-changes/app/coffee/DiffGenerator.coffee new file mode 100644 index 0000000000..9ea4682e31 --- /dev/null +++ b/services/track-changes/app/coffee/DiffGenerator.coffee @@ -0,0 +1,168 @@ +ConsistencyError = (message) -> + error = new Error(message) + error.name = "ConsistencyError" + error.__proto__ = ConsistencyError.prototype + return error +ConsistencyError.prototype.__proto__ = Error.prototype + +module.exports = DiffGenerator = + ConsistencyError: ConsistencyError + + rewindUpdate: (content, update) -> + op = update.op + if op.i? + textToBeRemoved = content.slice(op.p, op.p + op.i.length) + if op.i != textToBeRemoved + throw new ConsistencyError( + "Inserted content, '#{op.i}', does not match text to be removed, '#{textToBeRemoved}'" + ) + + return content.slice(0, op.p) + content.slice(op.p + op.i.length) + + else if op.d? + return content.slice(0, op.p) + op.d + content.slice(op.p) + + rewindUpdates: (content, updates) -> + for update in updates.reverse() + content = DiffGenerator.rewindUpdate(content, update) + return content + + buildDiff: (initialContent, updates) -> + + applyUpdateToDiff: (diff, update) -> + position = 0 + op = update.op + + remainingDiff = diff.slice() + {consumedDiff, remainingDiff} = DiffGenerator._consumeToOffset(remainingDiff, op.p) + newDiff = consumedDiff + + if op.i? + newDiff.push + i: op.i + meta: update.meta + else if op.d? + {consumedDiff, remainingDiff} = DiffGenerator._consumeDiffAffectedByDeleteUpdate remainingDiff, update + newDiff.push(consumedDiff...) + + newDiff.push(remainingDiff...) + + return newDiff + + + _consumeToOffset: (remainingDiff, totalOffset) -> + consumedDiff = [] + position = 0 + while part = remainingDiff.shift() + length = DiffGenerator._getLengthOfDiffPart part + if part.d? + consumedDiff.push part + else if position + length >= totalOffset + partOffset = totalOffset - position + if partOffset > 0 + consumedDiff.push DiffGenerator._slicePart part, 0, partOffset + if partOffset < length + remainingDiff.unshift DiffGenerator._slicePart part, partOffset + return { + consumedDiff: consumedDiff + remainingDiff: remainingDiff + } + else + position += length + consumedDiff.push part + throw new Error("Ran out of diff to consume. Offset is too small") + + _consumeDiffAffectedByDeleteUpdate: (remainingDiff, deleteUpdate) -> + consumedDiff = [] + remainingUpdate = deleteUpdate + while remainingUpdate + {newPart, remainingDiff, remainingUpdate} = DiffGenerator._consumeDeletedPart remainingDiff, remainingUpdate + consumedDiff.push newPart if newPart? + return { + consumedDiff: consumedDiff + remainingDiff: remainingDiff + } + + _consumeDeletedPart: (remainingDiff, deleteUpdate) -> + part = remainingDiff.shift() + partLength = DiffGenerator._getLengthOfDiffPart part + op = deleteUpdate.op + + if part.d? + # Skip existing deletes + remainingUpdate = deleteUpdate + newPart = part + + else if partLength > op.d.length + # Only the first bit of the part has been deleted + remainingPart = DiffGenerator._slicePart part, op.d.length + remainingDiff.unshift remainingPart + + deletedContent = DiffGenerator._getContentOfPart(part).slice(0, op.d.length) + if deletedContent != op.d + throw new ConsistencyError("deleted content, '#{deletedContent}', does not match delete op, '#{op.d}'") + + if part.u? + newPart = + d: op.d + meta: deleteUpdate.meta + else if part.i? + newPart = null + + remainingUpdate = null + + else if partLength == op.d.length + # The entire part has been deleted, but it is the last part + + deletedContent = DiffGenerator._getContentOfPart(part) + if deletedContent != op.d + throw new ConsistencyError("deleted content, '#{deletedContent}', does not match delete op, '#{op.d}'") + + if part.u? + newPart = + d: op.d + meta: deleteUpdate.meta + else if part.i? + newPart = null + + remainingUpdate = null + + else if partLength < op.d.length + # The entire part has been deleted and there is more + + deletedContent = DiffGenerator._getContentOfPart(part) + opContent = op.d.slice(0, deletedContent.length) + if deletedContent != opContent + throw new ConsistencyError("deleted content, '#{deletedContent}', does not match delete op, '#{opContent}'") + + if part.u + newPart = + d: part.u + meta: deleteUpdate.meta + else if part.i? + newPart = null + + remainingUpdate = + op: { p: op.p, d: op.d.slice(DiffGenerator._getLengthOfDiffPart(part)) } + meta: deleteUpdate.meta + + return { + newPart: newPart + remainingDiff: remainingDiff + remainingUpdate: remainingUpdate + } + + _slicePart: (basePart, from, to) -> + if basePart.u? + part = { u: basePart.u.slice(from, to) } + else if basePart.i? + part = { i: basePart.i.slice(from, to) } + if basePart.meta? + part.meta = basePart.meta + return part + + _getLengthOfDiffPart: (part) -> + (part.u or part.d or part.i).length + + _getContentOfPart: (part) -> + part.u or part.d or part.i diff --git a/services/track-changes/test/unit/coffee/DiffGenerator/DiffGeneratorTests.coffee b/services/track-changes/test/unit/coffee/DiffGenerator/DiffGeneratorTests.coffee new file mode 100644 index 0000000000..47c4561074 --- /dev/null +++ b/services/track-changes/test/unit/coffee/DiffGenerator/DiffGeneratorTests.coffee @@ -0,0 +1,238 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/DiffGenerator.js" +SandboxedModule = require('sandboxed-module') + +describe "DiffGenerator", -> + beforeEach -> + @DiffGenerator = SandboxedModule.require modulePath + @ts = Date.now() + @user_id = "mock-user-id" + @meta = { + start_ts: @ts, end_ts: @ts, user_id: @user_id + } + + describe "rewindUpdate", -> + describe "rewinding an insert", -> + it "should undo the insert", -> + content = "hello world" + update = + op: { p: 6, i: "wo" } + rewoundContent = @DiffGenerator.rewindUpdate content, update + rewoundContent.should.equal "hello rld" + + describe "rewinding a delete", -> + it "should undo the delete", -> + content = "hello rld" + update = + op: { p: 6, d: "wo" } + rewoundContent = @DiffGenerator.rewindUpdate content, update + rewoundContent.should.equal "hello world" + + describe "with an inconsistent update", -> + it "should throw an error", -> + content = "hello world" + update = + op: { p: 6, i: "foo" } + expect( () => + @DiffGenerator.rewindUpdate content, update + ).to.throw(@DiffGenerator.ConsistencyError) + + describe "rewindUpdates", -> + it "should rewind updates in reverse", -> + content = "aaabbbccc" + updates = [ + { op: { p: 3, i: "bbb" } }, + { op: { p: 6, i: "ccc" } } + ] + rewoundContent = @DiffGenerator.rewindUpdates content, updates + rewoundContent.should.equal "aaa" + + describe "applyUpdateToDiff", -> + describe "an insert", -> + it "should insert into the middle of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobar" } ], + { op: { p: 3, i: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "foo" } + { i: "baz", meta: @meta } + { u: "bar" } + ]) + + it "should insert into the start of (u)changed text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobar" } ], + { op: { p: 0, i: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { i: "baz", meta: @meta } + { u: "foobar" } + ]) + + it "should insert into the end of (u)changed text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobar" } ], + { op: { p: 6, i: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "foobar" } + { i: "baz", meta: @meta } + ]) + + it "should insert into the middle of (i)inserted text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { i: "foobar", meta: @meta } ], + { op: { p: 3, i: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { i: "foo", meta: @meta } + { i: "baz", meta: @meta } + { i: "bar", meta: @meta } + ]) + + it "should not count deletes in the running length total", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ + { d: "deleted", meta: @meta } + { u: "foobar" } + ], + { op: { p: 3, i: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { d: "deleted", meta: @meta } + { u: "foo" } + { i: "baz", meta: @meta } + { u: "bar" } + ]) + + describe "a delete", -> + describe "deleting unchanged text", -> + it "should delete from the middle of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 3, d: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "foo" } + { d: "baz", meta: @meta } + { u: "bar" } + ]) + + it "should delete from the start of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 0, d: "foo" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { d: "foo", meta: @meta } + { u: "bazbar" } + ]) + + it "should delete from the end of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 6, d: "bar" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "foobaz" } + { d: "bar", meta: @meta } + ]) + + it "should delete across multiple (u)changed text parts", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foo" }, { u: "baz" }, { u: "bar" } ], + { op: { p: 2, d: "obazb" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "fo" } + { d: "o", meta: @meta } + { d: "baz", meta: @meta } + { d: "b", meta: @meta } + { u: "ar" } + ]) + + describe "deleting inserts", -> + it "should delete from the middle of (i)nserted text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { i: "foobazbar", meta: @meta } ], + { op: { p: 3, d: "baz" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { i: "foo", meta: @meta } + { i: "bar", meta: @meta } + ]) + + it "should delete from the start of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { i: "foobazbar", meta: @meta } ], + { op: { p: 0, d: "foo" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { i: "bazbar", meta: @meta } + ]) + + it "should delete from the end of (u)nchanged text", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { i: "foobazbar", meta: @meta } ], + { op: { p: 6, d: "bar" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { i: "foobaz", meta: @meta } + ]) + + it "should delete across multiple (u)changed and (i)nserted text parts", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foo" }, { i: "baz", meta: @meta }, { u: "bar" } ], + { op: { p: 2, d: "obazb" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "fo" } + { d: "o", meta: @meta } + { d: "b", meta: @meta } + { u: "ar" } + ]) + + describe "deleting over existing deletes", -> + it "should delete across multiple (u)changed and (d)deleted text parts", -> + diff = @DiffGenerator.applyUpdateToDiff( + [ { u: "foo" }, { d: "baz", meta: @meta }, { u: "bar" } ], + { op: { p: 2, d: "ob" }, meta: @meta } + ) + expect(diff).to.deep.equal([ + { u: "fo" } + { d: "o", meta: @meta } + { d: "baz", meta: @meta } + { d: "b", meta: @meta } + { u: "ar" } + ]) + + describe "deleting when the text doesn't match", -> + it "should throw an error when deleting from the middle of (u)nchanged text", -> + expect( + () => @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 3, d: "xxx" }, meta: @meta } + ) + ).to.throw(@DiffGenerator.ConsistencyError) + + it "should throw an error when deleting from the start of (u)nchanged text", -> + expect( + () => @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 0, d: "xxx" }, meta: @meta } + ) + ).to.throw(@DiffGenerator.ConsistencyError) + + it "should throw an error when deleting from the end of (u)nchanged text", -> + expect( + () => @DiffGenerator.applyUpdateToDiff( + [ { u: "foobazbar" } ], + { op: { p: 6, d: "xxx" }, meta: @meta } + ) + ).to.throw(@DiffGenerator.ConsistencyError) + +