Просмотр исходного кода

Add in diff generating functions

James Allen 12 лет назад
Родитель
Сommit
e0402692cf

+ 2 - 2
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")

+ 168 - 0
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

+ 238 - 0
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)
+
+