diff --git a/services/track-changes/app/coffee/ConversionManager.coffee b/services/track-changes/app/coffee/ConversionManager.coffee deleted file mode 100644 index 2db3aaad51..0000000000 --- a/services/track-changes/app/coffee/ConversionManager.coffee +++ /dev/null @@ -1,48 +0,0 @@ -{db, ObjectId} = require "./mongojs" -HistoryBuilder = require "./HistoryBuilder" -logger = require "logger-sharelatex" - -module.exports = ConversionManager = - OPS_TO_LEAVE: 100 - - popLastCompressedOp: (doc_id, callback = (error, op) ->) -> - db.docHistory.findAndModify - query: { doc_id: ObjectId(doc_id) } - fields: { docOps: { $slice: -1 } } - update: { $pop: { docOps: 1 } } - , (error, history = { docOps: [] }) -> - return callback(error) if error? - callback null, history.docOps[0] - - insertCompressedOps: (doc_id, docOps, callback = (error) ->) -> - db.docHistory.update { - doc_id: ObjectId(doc_id) - }, { - $push: - docOps: - $each: docOps - }, { - upsert: true - }, callback - - convertAndSaveRawOps: (doc_id, rawOps, callback = (error) ->) -> - length = rawOps.length - if length == 0 - return callback() - - - ConversionManager.popLastCompressedOp doc_id, (error, lastCompressedOp) -> - return callback(error) if error? - - if !lastCompressedOp? - rawOps = rawOps.slice(0) # Clone so we can modify in place - lastCompressedOp = rawOps.shift() - - uncompressedOps = [lastCompressedOp].concat rawOps - compressedOps = HistoryBuilder.compressOps uncompressedOps - - ConversionManager.insertCompressedOps doc_id, compressedOps, (error) -> - return callback(error) if error? - logger.log doc_id: doc_id, rawOpsLength: length, compressedOpsLength: compressedOps.length, "compressed doc ops" - callback() - diff --git a/services/track-changes/app/coffee/HistoryBuilder.coffee b/services/track-changes/app/coffee/HistoryBuilder.coffee deleted file mode 100644 index 2a9405f439..0000000000 --- a/services/track-changes/app/coffee/HistoryBuilder.coffee +++ /dev/null @@ -1,150 +0,0 @@ -strInject = (s1, pos, s2) -> s1[...pos] + s2 + s1[pos..] -strRemove = (s1, pos, length) -> s1[...pos] + s1[(pos + length)..] - -module.exports = HistoryBuilder = - normalizeUpdate: (update) -> - if update.meta.start_ts? - return [update] # already normalized - - updates = [] - for op in update.op - updates.push - op: [op] - meta: - start_ts: update.meta.ts - end_ts: update.meta.ts - user_id: update.meta.user_id - return updates - - compressUpdates: (rawUpdates) -> - return [] if rawUpdates.length == 0 - normalizedUpdates = [] - for rawUpdate in rawUpdates - normalizedUpdates = normalizedUpdates.concat HistoryBuilder.normalizeUpdate(rawUpdate) - - return [] if normalizedUpdates.length == 0 - firstPass = [normalizedUpdates.shift()] - for update in normalizedUpdates - lastCompressedUpdate = firstPass.pop() - if lastCompressedUpdate? - firstPass = firstPass.concat HistoryBuilder._concatTwoUpdates lastCompressedUpdate, update, false - else - firstPass.push rawUpdate - - return [] if firstPass.length == 0 - secondPass = [firstPass.shift()] - for update in firstPass - lastCompressedUpdate = secondPass.pop() - if lastCompressedUpdate? - secondPass = secondPass.concat HistoryBuilder._concatTwoUpdates lastCompressedUpdate, update, true - else - secondPass.push update - - return secondPass - - MAX_TIME_BETWEEN_UPDATES: oneMinute = 60 * 1000 - - _concatTwoUpdates: (firstUpdate, secondUpdate, mergeInsertsAndDeletes) -> - firstUpdate = - op: firstUpdate.op - meta: - user_id: firstUpdate.meta.user_id or null - start_ts: firstUpdate.meta.start_ts or firstUpdate.meta.ts - end_ts: firstUpdate.meta.end_ts or firstUpdate.meta.ts - secondUpdate = - op: secondUpdate.op - meta: - user_id: secondUpdate.meta.user_id or null - start_ts: secondUpdate.meta.start_ts or secondUpdate.meta.ts - end_ts: secondUpdate.meta.end_ts or secondUpdate.meta.ts - - if firstUpdate.meta.user_id != secondUpdate.meta.user_id - return [firstUpdate, secondUpdate] - - if secondUpdate.meta.start_ts - firstUpdate.meta.end_ts > HistoryBuilder.MAX_TIME_BETWEEN_UPDATES - return [firstUpdate, secondUpdate] - - firstOp = firstUpdate.op[0] - secondOp = secondUpdate.op[0] - # Two inserts - if firstOp.i? and secondOp.i? and firstOp.p <= secondOp.p <= (firstOp.p + firstOp.i.length) - return [ - meta: - start_ts: firstUpdate.meta.start_ts - end_ts: secondUpdate.meta.end_ts - user_id: firstUpdate.meta.user_id - op: [ - p: firstOp.p - i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i) - ] - ] - # Two deletes - else if firstOp.d? and secondOp.d? and secondOp.p <= firstOp.p <= (secondOp.p + secondOp.d.length) - return [ - meta: - start_ts: firstUpdate.meta.start_ts - end_ts: secondUpdate.meta.end_ts - user_id: firstUpdate.meta.user_id - op: [ - p: secondOp.p - d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d) - ] - ] - # An insert and then a delete - if mergeInsertsAndDeletes and firstOp.i? and secondOp.d? and firstOp.p <= secondOp.p <= (firstOp.p + firstOp.i.length) - offset = secondOp.p - firstOp.p - insertedText = firstOp.i.slice(offset, offset + secondOp.d.length) - if insertedText == secondOp.d - insert = strRemove(firstOp.i, offset, secondOp.d.length) - return [] if insert == "" - return [ - meta: - start_ts: firstUpdate.meta.start_ts - end_ts: secondUpdate.meta.end_ts - user_id: firstUpdate.meta.user_id - op: [ - p: firstOp.p - i: insert - ] - ] - else - # This shouldn't be possible! - return [firstUpdate, secondUpdate] - else if mergeInsertsAndDeletes and firstOp.d? and secondOp.i? and firstOp.p == secondOp.p - offset = firstOp.d.indexOf(secondOp.i) - if offset == -1 - return [firstUpdate, secondUpdate] - headD = firstOp.d.slice(0, offset) - inserted = firstOp.d.slice(offset, secondOp.i.length) - tailD = firstOp.d.slice(offset + secondOp.i.length) - headP = firstOp.p - tailP = firstOp.p + secondOp.i.length - updates = [] - if headD != "" - updates.push - meta: - start_ts: firstUpdate.meta.start_ts - end_ts: secondUpdate.meta.end_ts - user_id: firstUpdate.meta.user_id - op: [ - p: headP - d: headD - ] - if tailD != "" - updates.push - meta: - start_ts: firstUpdate.meta.start_ts - end_ts: secondUpdate.meta.end_ts - user_id: firstUpdate.meta.user_id - op: [ - p: tailP - d: tailD - ] - if updates.length == 2 - updates[0].meta.start_ts = updates[0].meta.end_ts = firstUpdate.meta.start_ts - updates[1].meta.start_ts = updates[1].meta.end_ts = secondUpdate.meta.end_ts - return updates - - else - return [firstUpdate, secondUpdate] - diff --git a/services/track-changes/app/coffee/HistoryManager.coffee b/services/track-changes/app/coffee/HistoryManager.coffee new file mode 100644 index 0000000000..bf7a4f6510 --- /dev/null +++ b/services/track-changes/app/coffee/HistoryManager.coffee @@ -0,0 +1,54 @@ +{db, ObjectId} = require "./mongojs" +UpdateCompressor = require "./UpdateCompressor" +logger = require "logger-sharelatex" + +module.exports = HistoryManager = + getLastCompressedUpdate: (doc_id, callback = (error, update) ->) -> + db.docHistory + .find(doc_id: ObjectId(doc_id.toString())) + .sort(timestamp: -1) + .limit(1) + .toArray (error, compressedUpdates) -> + return callback(error) if error? + return callback null, compressedUpdates[0] or null + + deleteCompressedUpdate: (id, callback = (error) ->) -> + db.docHistory.delete({ _id: ObjectId(id.toString()) }, callback) + + popLastCompressedUpdate: (doc_id, callback = (error, update) ->) -> + HistoryManager.getLastCompressedUpdate doc_id, (error, update) -> + return callback(error) if error? + if update? + HistoryManager.deleteCompressedUpdate update._id, (error) -> + return callback(error) if error? + callback null, update + else + callback null, null + + insertCompressedUpdates: (doc_id, docUpdates, callback = (error) ->) -> + db.docHistory.update { + doc_id: ObjectId(doc_id) + }, { + $push: + docUpdates: + $each: docUpdates + }, { + upsert: true + }, callback + + compressAndSaveRawUpdates: (doc_id, rawUpdates, callback = (error) ->) -> + length = rawUpdates.length + if length == 0 + return callback() + + + HistoryManager.popLastCompressedUpdate doc_id, (error, lastCompressedUpdate) -> + return callback(error) if error? + + compressedUpdates = UpdateCompressor.compressRawUpdates lastCompressedUpdate, rawUpdates + + HistoryManager.insertCompressedUpdates doc_id, compressedUpdates, (error) -> + return callback(error) if error? + logger.log doc_id: doc_id, rawUpdatesLength: length, compressedUpdatesLength: compressedUpdates.length, "compressed doc updates" + callback() + diff --git a/services/track-changes/app/coffee/UpdateCompressor.coffee b/services/track-changes/app/coffee/UpdateCompressor.coffee new file mode 100644 index 0000000000..5a69cb6e6b --- /dev/null +++ b/services/track-changes/app/coffee/UpdateCompressor.coffee @@ -0,0 +1,118 @@ +strInject = (s1, pos, s2) -> s1[...pos] + s2 + s1[pos..] +strRemove = (s1, pos, length) -> s1[...pos] + s1[(pos + length)..] + +module.exports = UpdateCompressor = + # Updates come from the doc updater in format + # { + # op: [ { ... op1 ... }, { ... op2 ... } ] + # meta: { ts: ..., user_id: ... } + # } + # but it's easier to work with on op per update, so convert these updates to + # our compressed format + # [{ + # op: op1 + # meta: { start_ts: ... , end_ts: ..., user_id: ... } + # }, { + # op: op2 + # meta: { start_ts: ... , end_ts: ..., user_id: ... } + # }] + convertRawUpdatesToCompressedFormat: (updates) -> + normalizedUpdates = [] + for update in updates + for op in update.op + normalizedUpdates.push + op: op + meta: + start_ts: update.meta.start_ts or update.meta.ts + end_ts: update.meta.end_ts or update.meta.ts + user_id: update.meta.user_id + return normalizedUpdates + + compressRawUpdates: (lastPreviousUpdate, rawUpdates) -> + updates = UpdateCompressor.convertRawUpdatesToCompressedFormat(rawUpdates) + if lastPreviousUpdate? + updates.unshift(lastPreviousUpdate) + return UpdateCompressor.compressUpdates(updates) + + compressUpdates: (updates) -> + return [] if updates.length == 0 + + compressedUpdates = [updates.shift()] + for update in updates + lastCompressedUpdate = compressedUpdates.pop() + if lastCompressedUpdate? + compressedUpdates = compressedUpdates.concat UpdateCompressor._concatTwoUpdates lastCompressedUpdate, update + else + compressedUpdates.push update + + return compressedUpdates + + MAX_TIME_BETWEEN_UPDATES: oneMinute = 60 * 1000 + + _concatTwoUpdates: (firstUpdate, secondUpdate) -> + firstUpdate = + op: firstUpdate.op + meta: + user_id: firstUpdate.meta.user_id or null + start_ts: firstUpdate.meta.start_ts or firstUpdate.meta.ts + end_ts: firstUpdate.meta.end_ts or firstUpdate.meta.ts + secondUpdate = + op: secondUpdate.op + meta: + user_id: secondUpdate.meta.user_id or null + start_ts: secondUpdate.meta.start_ts or secondUpdate.meta.ts + end_ts: secondUpdate.meta.end_ts or secondUpdate.meta.ts + + if firstUpdate.meta.user_id != secondUpdate.meta.user_id + return [firstUpdate, secondUpdate] + + if secondUpdate.meta.start_ts - firstUpdate.meta.end_ts > UpdateCompressor.MAX_TIME_BETWEEN_UPDATES + return [firstUpdate, secondUpdate] + + firstOp = firstUpdate.op + secondOp = secondUpdate.op + # Two inserts + if firstOp.i? and secondOp.i? and firstOp.p <= secondOp.p <= (firstOp.p + firstOp.i.length) + return [ + meta: + start_ts: firstUpdate.meta.start_ts + end_ts: secondUpdate.meta.end_ts + user_id: firstUpdate.meta.user_id + op: + p: firstOp.p + i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i) + ] + # Two deletes + else if firstOp.d? and secondOp.d? and secondOp.p <= firstOp.p <= (secondOp.p + secondOp.d.length) + return [ + meta: + start_ts: firstUpdate.meta.start_ts + end_ts: secondUpdate.meta.end_ts + user_id: firstUpdate.meta.user_id + op: + p: secondOp.p + d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d) + ] + # An insert and then a delete + else if firstOp.i? and secondOp.d? and firstOp.p <= secondOp.p <= (firstOp.p + firstOp.i.length) + offset = secondOp.p - firstOp.p + insertedText = firstOp.i.slice(offset, offset + secondOp.d.length) + if insertedText == secondOp.d + insert = strRemove(firstOp.i, offset, secondOp.d.length) + return [] if insert == "" + return [ + meta: + start_ts: firstUpdate.meta.start_ts + end_ts: secondUpdate.meta.end_ts + user_id: firstUpdate.meta.user_id + op: + p: firstOp.p + i: insert + ] + else + # This shouldn't be possible! + return [firstUpdate, secondUpdate] + + else + return [firstUpdate, secondUpdate] + diff --git a/services/track-changes/test/unit/coffee/ConversionManager/ConversionManagerTests.coffee b/services/track-changes/test/unit/coffee/ConversionManager/ConversionManagerTests.coffee deleted file mode 100644 index cdc72b69ca..0000000000 --- a/services/track-changes/test/unit/coffee/ConversionManager/ConversionManagerTests.coffee +++ /dev/null @@ -1,87 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -expect = chai.expect -modulePath = "../../../../app/js/ConversionManager.js" -SandboxedModule = require('sandboxed-module') - -describe "ConversionManager", -> - beforeEach -> - @ConversionManager = SandboxedModule.require modulePath, requires: - "./HistoryBuilder": @HistoryBuilder = {} - "./mongojs" : {} - "logger-sharelatex": { log: sinon.stub() } - @doc_id = "doc-id-123" - @callback = sinon.stub() - - describe "when there are no raw ops", -> - beforeEach -> - @ConversionManager.popLastCompressedOp = sinon.stub() - @ConversionManager.insertCompressedOps = sinon.stub() - @ConversionManager.convertAndSaveRawOps @doc_id, [], @callback - - it "should not need to access the database", -> - @ConversionManager.popLastCompressedOp.called.should.equal false - @ConversionManager.insertCompressedOps.called.should.equal false - - it "should call the callback", -> - @callback.called.should.equal true - - describe "when there is no compressed history to begin with", -> - beforeEach -> - @rawOps = ["mock-raw-op-1", "mock-raw-op-2"] - @compressedOps = ["mock-compressed-op"] - - @ConversionManager.popLastCompressedOp = sinon.stub().callsArgWith(1, null, null) - @ConversionManager.insertCompressedOps = sinon.stub().callsArg(2) - @HistoryBuilder.compressOps = sinon.stub().returns(@compressedOps) - @ConversionManager.convertAndSaveRawOps @doc_id, @rawOps, @callback - - it "should try to pop the last compressed op", -> - @ConversionManager.popLastCompressedOp - .calledWith(@doc_id) - .should.equal true - - it "should compress the raw ops", -> - @HistoryBuilder.compressOps - .calledWith(@rawOps) - .should.equal true - - it "should save the compressed ops", -> - @ConversionManager.insertCompressedOps - .calledWith(@doc_id, @compressedOps) - .should.equal true - - it "should call the callback", -> - @callback.called.should.equal true - - describe "when the raw ops need appending to existing history", -> - beforeEach -> - @rawOps = ["mock-raw-op-1", "mock-raw-op-2"] - @lastCompressedOp = "mock-last-compressed-op-0" - @compressedOps = ["mock-compressed-op-1"] - - @ConversionManager.popLastCompressedOp = sinon.stub().callsArgWith(1, null, @lastCompressedOp) - @ConversionManager.insertCompressedOps = sinon.stub().callsArg(2) - @HistoryBuilder.compressOps = sinon.stub().returns(@compressedOps) - @ConversionManager.convertAndSaveRawOps @doc_id, @rawOps, @callback - - it "should try to pop the last compressed op", -> - @ConversionManager.popLastCompressedOp - .calledWith(@doc_id) - .should.equal true - - it "should compress the last compressed op and the raw ops", -> - @HistoryBuilder.compressOps - .calledWith([@lastCompressedOp].concat(@rawOps)) - .should.equal true - - it "should save the compressed ops", -> - @ConversionManager.insertCompressedOps - .calledWith(@doc_id, @compressedOps) - .should.equal true - - it "should call the callback", -> - @callback.called.should.equal true - - diff --git a/services/track-changes/test/unit/coffee/HistoryBuilder/HistoryBuilderTests.coffee b/services/track-changes/test/unit/coffee/HistoryBuilder/HistoryBuilderTests.coffee deleted file mode 100644 index bcbb0a157a..0000000000 --- a/services/track-changes/test/unit/coffee/HistoryBuilder/HistoryBuilderTests.coffee +++ /dev/null @@ -1,240 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -expect = chai.expect -modulePath = "../../../../app/js/HistoryBuilder.js" -SandboxedModule = require('sandboxed-module') - -describe "HistoryBuilder", -> - beforeEach -> - @HistoryBuilder = SandboxedModule.require modulePath - @user_id = "user-id-1" - @other_user_id = "user-id-2" - @ts1 = Date.now() - @ts2 = Date.now() + 1000 - - describe "compress", -> - describe "insert - insert", -> - it "should append one insert to the other", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 6, i: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "foobar" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should insert one insert inside the other", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 5, i: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "fobaro" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should not append separated inserts", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, i: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "foo" ] - meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, i: "bar" ] - meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id - }] - - describe "delete - delete", -> - it "should append one delete to the other", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, d: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, d: "foobar" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should insert one delete inside the other", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 1, d: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 1, d: "bafoor" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should not append separated deletes", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, d: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, d: "foo" ] - meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, d: "bar" ] - meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id - }] - - describe "insert - delete", -> - it "should undo a previous insert", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 5, d: "o" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "fo" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should remove part of an insert from the middle", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "fobaro" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 5, d: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "foo" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should cancel out two opposite updates", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, d: "foo" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [] - - it "should not combine separated updates", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, i: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, d: "bar" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, i: "foo" ] - meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id - }, { - op: [ p: 9, d: "bar" ] - meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id - }] - - describe "delete - insert", -> - it "should redo a previous delete at the beginning", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "f" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 4, d: "oo" ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - it "should redo a previous delete from halfway through", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foobar" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "oo" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, d: "f" ] - meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id - }, { - op: [ p: 5, d: "bar" ] - meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id - }] - - it "should keep words together", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "abcdefghijklmnopqrstuvwxyz hello world" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "w" ] - meta: ts: @ts2, user_id: @user_id - }, { - op: [ p: 4, i: "o" ] - meta: ts: @ts2, user_id: @user_id - }, { - op: [ p: 5, i: "r" ] - meta: ts: @ts2, user_id: @user_id - }, { - op: [ p: 6, i: "l" ] - meta: ts: @ts2, user_id: @user_id - }, { - op: [ p: 7, i: "d" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, d: "abcdefghijklmnopqrstuvwxyz hello " ] - meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id - }] - - - it "should not combine the ops if the insert text does not match the delete text", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foobar" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "xy" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [{ - op: [ p: 3, d: "foobar" ] - meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "xy" ] - meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id - }] - - it "should cancel two equal updates", -> - expect(@HistoryBuilder.compressUpdates [{ - op: [ p: 3, d: "foo" ] - meta: ts: @ts1, user_id: @user_id - }, { - op: [ p: 3, i: "foo" ] - meta: ts: @ts2, user_id: @user_id - }]) - .to.deep.equal [] - - - - diff --git a/services/track-changes/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee b/services/track-changes/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee new file mode 100644 index 0000000000..9750f95461 --- /dev/null +++ b/services/track-changes/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee @@ -0,0 +1,87 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/HistoryManager.js" +SandboxedModule = require('sandboxed-module') + +describe "HistoryManager", -> + beforeEach -> + @HistoryManager = SandboxedModule.require modulePath, requires: + "./UpdateCompressor": @UpdateCompressor = {} + "./mongojs" : {} + "logger-sharelatex": { log: sinon.stub() } + @doc_id = "doc-id-123" + @callback = sinon.stub() + + describe "when there are no raw ops", -> + beforeEach -> + @HistoryManager.popLastCompressedUpdate = sinon.stub() + @HistoryManager.insertCompressedUpdates = sinon.stub() + @HistoryManager.compressAndSaveRawUpdates @doc_id, [], @callback + + it "should not need to access the database", -> + @HistoryManager.popLastCompressedUpdate.called.should.equal false + @HistoryManager.insertCompressedUpdates.called.should.equal false + + it "should call the callback", -> + @callback.called.should.equal true + + describe "when there is no compressed history to begin with", -> + beforeEach -> + @rawUpdates = ["mock-raw-op-1", "mock-raw-op-2"] + @compressedUpdates = ["mock-compressed-op"] + + @HistoryManager.popLastCompressedUpdate = sinon.stub().callsArgWith(1, null, null) + @HistoryManager.insertCompressedUpdates = sinon.stub().callsArg(2) + @UpdateCompressor.compressRawUpdates = sinon.stub().returns(@compressedUpdates) + @HistoryManager.compressAndSaveRawUpdates @doc_id, @rawUpdates, @callback + + it "should try to pop the last compressed op", -> + @HistoryManager.popLastCompressedUpdate + .calledWith(@doc_id) + .should.equal true + + it "should compress the raw ops", -> + @UpdateCompressor.compressRawUpdates + .calledWith(null, @rawUpdates) + .should.equal true + + it "should save the compressed ops", -> + @HistoryManager.insertCompressedUpdates + .calledWith(@doc_id, @compressedUpdates) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true + + describe "when the raw ops need appending to existing history", -> + beforeEach -> + @rawUpdates = ["mock-raw-op-1", "mock-raw-op-2"] + @lastCompressedUpdate = "mock-last-compressed-op-0" + @compressedUpdates = ["mock-compressed-op-1"] + + @HistoryManager.popLastCompressedUpdate = sinon.stub().callsArgWith(1, null, @lastCompressedUpdate) + @HistoryManager.insertCompressedUpdates = sinon.stub().callsArg(2) + @UpdateCompressor.compressRawUpdates = sinon.stub().returns(@compressedUpdates) + @HistoryManager.compressAndSaveRawUpdates @doc_id, @rawUpdates, @callback + + it "should try to pop the last compressed op", -> + @HistoryManager.popLastCompressedUpdate + .calledWith(@doc_id) + .should.equal true + + it "should compress the last compressed op and the raw ops", -> + @UpdateCompressor.compressRawUpdates + .calledWith(@lastCompressedUpdate, @rawUpdates) + .should.equal true + + it "should save the compressed ops", -> + @HistoryManager.insertCompressedUpdates + .calledWith(@doc_id, @compressedUpdates) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true + + diff --git a/services/track-changes/test/unit/coffee/UpdateCompressor/UpdateCompressorTests.coffee b/services/track-changes/test/unit/coffee/UpdateCompressor/UpdateCompressorTests.coffee new file mode 100644 index 0000000000..21cb02b13d --- /dev/null +++ b/services/track-changes/test/unit/coffee/UpdateCompressor/UpdateCompressorTests.coffee @@ -0,0 +1,176 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/UpdateCompressor.js" +SandboxedModule = require('sandboxed-module') + +describe "UpdateCompressor", -> + beforeEach -> + @UpdateCompressor = SandboxedModule.require modulePath + @user_id = "user-id-1" + @other_user_id = "user-id-2" + @ts1 = Date.now() + @ts2 = Date.now() + 1000 + + describe "convertRawUpdatesToCompressedFormat", -> + it "should split grouped updates into individual updates", -> + expect(@UpdateCompressor.convertRawUpdatesToCompressedFormat [{ + op: [ @op1 = { p: 0, i: "Foo" }, @op2 = { p: 6, i: "bar"} ] + meta: { ts: @ts1, user_id: @user_id } + }, { + op: [ @op3 = { p: 10, i: "baz" } ] + meta: { ts: @ts2, user_id: @other_user_id } + }]) + .to.deep.equal [{ + op: @op1, + meta: { start_ts: @ts1, end_ts: @ts1, user_id: @user_id } + }, { + op: @op2, + meta: { start_ts: @ts1, end_ts: @ts1, user_id: @user_id } + }, { + op: @op3, + meta: { start_ts: @ts2, end_ts: @ts2, user_id: @other_user_id } + }] + + describe "compress", -> + describe "insert - insert", -> + it "should append one insert to the other", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 6, i: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "foobar" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should insert one insert inside the other", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 5, i: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "fobaro" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should not append separated inserts", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 9, i: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "foo" } + meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id + }, { + op: { p: 9, i: "bar" } + meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id + }] + + describe "delete - delete", -> + it "should append one delete to the other", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, d: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 3, d: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, d: "foobar" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should insert one delete inside the other", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, d: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 1, d: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 1, d: "bafoor" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should not append separated deletes", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, d: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 9, d: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, d: "foo" } + meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id + }, { + op: { p: 9, d: "bar" } + meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id + }] + + describe "insert - delete", -> + it "should undo a previous insert", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 5, d: "o" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "fo" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should remove part of an insert from the middle", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "fobaro" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 5, d: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "foo" } + meta: start_ts: @ts1, end_ts: @ts2, user_id: @user_id + }] + + it "should cancel out two opposite updates", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 3, d: "foo" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [] + + it "should not combine separated updates", -> + expect(@UpdateCompressor.compressUpdates [{ + op: { p: 3, i: "foo" } + meta: ts: @ts1, user_id: @user_id + }, { + op: { p: 9, d: "bar" } + meta: ts: @ts2, user_id: @user_id + }]) + .to.deep.equal [{ + op: { p: 3, i: "foo" } + meta: start_ts: @ts1, end_ts: @ts1, user_id: @user_id + }, { + op: { p: 9, d: "bar" } + meta: start_ts: @ts2, end_ts: @ts2, user_id: @user_id + }] + +