| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874 |
- import sinon from 'sinon'
- import { expect } from 'chai'
- import { strict as esmock } from 'esmock'
- const MODULE_PATH = '../../../../app/js/SummarizedUpdatesManager.js'
- // A sufficiently large amount of time to make the algorithm process updates
- // separately
- const LATER = 1000000
- describe('SummarizedUpdatesManager', function () {
- beforeEach(async function () {
- this.historyId = 'history-id-123'
- this.projectId = 'project-id-123'
- this.firstChunk = { chunk: { startVersion: 0 } }
- this.secondChunk = { chunk: { startVersion: 1 } }
- this.ChunkTranslator = {
- convertToSummarizedUpdates: sinon.stub(),
- }
- this.HistoryApiManager = {
- shouldUseProjectHistory: sinon.stub().yields(null, true),
- }
- this.HistoryStoreManager = {
- getMostRecentChunk: sinon.stub(),
- getChunkAtVersion: sinon.stub(),
- }
- this.UpdatesProcessor = {
- processUpdatesForProject: sinon.stub().withArgs(this.projectId).yields(),
- }
- this.WebApiManager = {
- getHistoryId: sinon.stub().yields(null, this.historyId),
- }
- this.LabelsManager = {
- getLabels: sinon.stub().yields(null, []),
- }
- this.SummarizedUpdatesManager = await esmock(MODULE_PATH, {
- '../../../../app/js/ChunkTranslator.js': this.ChunkTranslator,
- '../../../../app/js/HistoryApiManager.js': this.HistoryApiManager,
- '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
- '../../../../app/js/UpdatesProcessor.js': this.UpdatesProcessor,
- '../../../../app/js/WebApiManager.js': this.WebApiManager,
- '../../../../app/js/LabelsManager.js': this.LabelsManager,
- })
- this.callback = sinon.stub()
- })
- describe('getSummarizedProjectUpdates', function () {
- describe('chunk management', function () {
- describe('when there is a single empty chunk', function () {
- setupChunks([[]])
- expectSummaries('returns an empty list of updates', {}, [])
- })
- describe('when there is a single non-empty chunk', function () {
- setupChunks([[makeUpdate()]])
- expectSummaries('returns summarized updates', {}, [makeSummary()])
- })
- describe('when there are multiple chunks', function () {
- setupChunks([
- [makeUpdate({ startTs: 0, v: 1 })],
- [makeUpdate({ startTs: LATER, v: 2 })],
- ])
- describe('and requesting many summaries', function () {
- expectSummaries('returns many update summaries', {}, [
- makeSummary({ startTs: LATER, fromV: 2 }),
- makeSummary({ startTs: 0, fromV: 1 }),
- ])
- })
- describe('and requesting a single summary', function () {
- expectSummaries('returns a single update summary', { min_count: 1 }, [
- makeSummary({ startTs: LATER, fromV: 2 }),
- ])
- })
- })
- describe('when there are too many chunks', function () {
- // Set up 10 chunks
- const chunks = []
- for (let v = 1; v <= 10; v++) {
- chunks.push([
- makeUpdate({
- startTs: v * 100, // values: 100 - 1000
- v, // values: 1 - 10
- }),
- ])
- }
- setupChunks(chunks)
- // Verify that we stop summarizing after 5 chunks
- expectSummaries('summarizes the 5 latest chunks', {}, [
- makeSummary({ startTs: 600, endTs: 1010, fromV: 6, toV: 11 }),
- ])
- })
- describe('when requesting updates before a specific version', function () {
- // Chunk 1 contains 5 updates that were made close to each other and 5
- // other updates that were made later.
- const chunk1 = []
- for (let v = 1; v <= 5; v++) {
- chunk1.push(
- makeUpdate({
- startTs: v * 100, // values: 100 - 500
- v, // values: 1 - 5
- })
- )
- }
- for (let v = 6; v <= 10; v++) {
- chunk1.push(
- makeUpdate({
- startTs: LATER + v * 100, // values: 1000600 - 1001000
- v, // values: 6 - 10
- })
- )
- }
- // Chunk 2 contains 5 updates that were made close to the latest updates in
- // chunk 1.
- const chunk2 = []
- for (let v = 11; v <= 15; v++) {
- chunk2.push(
- makeUpdate({
- startTs: LATER + v * 100, // values: 1001100 - 1001500
- v, // values: 11 - 15
- })
- )
- }
- setupChunks([chunk1, chunk2])
- expectSummaries(
- 'summarizes the updates in a single chunk if the chunk is sufficient',
- { before: 14, min_count: 1 },
- [
- makeSummary({
- startTs: LATER + 1100,
- endTs: LATER + 1310,
- fromV: 11,
- toV: 14,
- }),
- ]
- )
- expectSummaries(
- 'summarizes the updates in many chunks otherwise',
- { before: 14, min_count: 2 },
- [
- makeSummary({
- startTs: LATER + 600,
- endTs: LATER + 1310,
- fromV: 6,
- toV: 14,
- }),
- makeSummary({
- startTs: 100,
- endTs: 510,
- fromV: 1,
- toV: 6,
- }),
- ]
- )
- })
- })
- describe('update summarization', function () {
- describe('updates that are close in time', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: ['user2'],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should merge the updates', {}, [
- makeSummary({
- users: ['user1', 'user2'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('updates that are far apart in time', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 100,
- v: 4,
- }),
- makeUpdate({
- users: ['user2'],
- startTs: LATER,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should not merge the updates', {}, [
- makeSummary({
- users: ['user2'],
- startTs: LATER,
- endTs: LATER + 10,
- fromV: 5,
- toV: 6,
- }),
- makeSummary({
- users: ['user1'],
- startTs: 100,
- endTs: 110,
- fromV: 4,
- toV: 5,
- }),
- ])
- })
- describe('mergeable updates in different chunks', function () {
- setupChunks([
- [
- makeUpdate({
- pathnames: ['main.tex'],
- users: ['user1'],
- startTs: 10,
- v: 4,
- }),
- makeUpdate({
- pathnames: ['main.tex'],
- users: ['user2'],
- startTs: 30,
- v: 5,
- }),
- ],
- [
- makeUpdate({
- pathnames: ['chapter.tex'],
- users: ['user1'],
- startTs: 40,
- v: 6,
- }),
- makeUpdate({
- pathnames: ['chapter.tex'],
- users: ['user1'],
- startTs: 50,
- v: 7,
- }),
- ],
- ])
- expectSummaries('should merge the updates', {}, [
- makeSummary({
- pathnames: ['main.tex', 'chapter.tex'],
- users: ['user1', 'user2'],
- startTs: 10,
- endTs: 60,
- fromV: 4,
- toV: 8,
- }),
- ])
- })
- describe('null user values after regular users', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: [null],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should include the null values', {}, [
- makeSummary({
- users: [null, 'user1'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('null user values before regular users', function () {
- setupChunks([
- [
- makeUpdate({
- users: [null],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: ['user1'],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should include the null values', {}, [
- makeSummary({
- users: [null, 'user1'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('multiple null user values', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 10,
- v: 4,
- }),
- makeUpdate({
- users: [null],
- startTs: 20,
- v: 5,
- }),
- makeUpdate({
- users: [null],
- startTs: 70,
- v: 6,
- }),
- ],
- ])
- expectSummaries('should merge the null values', {}, [
- makeSummary({
- users: [null, 'user1'],
- startTs: 10,
- endTs: 80,
- fromV: 4,
- toV: 7,
- }),
- ])
- })
- describe('multiple users', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: ['user2'],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should merge the users', {}, [
- makeSummary({
- users: ['user1', 'user2'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('duplicate updates with the same v1 user', function () {
- setupChunks([
- [
- makeUpdate({
- users: [{ id: 'user1' }],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: [{ id: 'user1' }],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should deduplicate the users', {}, [
- makeSummary({
- users: [{ id: 'user1' }],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('duplicate updates with the same v2 user', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: ['user1'],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should deduplicate the users', {}, [
- makeSummary({
- users: ['user1'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('mixed v1 and v2 users with the same id', function () {
- setupChunks([
- [
- makeUpdate({
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- users: [{ id: 'user1' }],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should deduplicate the users', {}, [
- makeSummary({
- users: [{ id: 'user1' }],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('project ops in mergeable updates', function () {
- setupChunks([
- [
- makeUpdate({
- pathnames: [],
- projectOps: [
- { rename: { pathname: 'C.tex', newPathname: 'D.tex' } },
- ],
- users: ['user2'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- pathnames: [],
- projectOps: [
- { rename: { pathname: 'A.tex', newPathname: 'B.tex' } },
- ],
- users: ['user1'],
- startTs: 20,
- v: 5,
- }),
- ],
- ])
- expectSummaries('should merge project ops', {}, [
- makeSummary({
- pathnames: [],
- projectOps: [
- {
- atV: 5,
- rename: {
- pathname: 'A.tex',
- newPathname: 'B.tex',
- },
- },
- {
- atV: 4,
- rename: {
- pathname: 'C.tex',
- newPathname: 'D.tex',
- },
- },
- ],
- users: ['user1', 'user2'],
- startTs: 0,
- endTs: 30,
- fromV: 4,
- toV: 6,
- }),
- ])
- })
- describe('mergable updates with a mix of project ops and doc ops', function () {
- setupChunks([
- [
- makeUpdate({
- pathnames: ['main.tex'],
- users: ['user1'],
- startTs: 0,
- v: 4,
- }),
- makeUpdate({
- pathnames: [],
- users: ['user2'],
- projectOps: [
- { rename: { pathname: 'A.tex', newPathname: 'B.tex' } },
- ],
- startTs: 20,
- v: 5,
- }),
- makeUpdate({
- pathnames: ['chapter.tex'],
- users: ['user2'],
- startTs: 40,
- v: 6,
- }),
- ],
- ])
- expectSummaries('should keep updates separate', {}, [
- makeSummary({
- pathnames: ['chapter.tex'],
- users: ['user2'],
- startTs: 40,
- fromV: 6,
- }),
- makeSummary({
- pathnames: [],
- users: ['user2'],
- projectOps: [
- { atV: 5, rename: { pathname: 'A.tex', newPathname: 'B.tex' } },
- ],
- startTs: 20,
- fromV: 5,
- }),
- makeSummary({
- pathnames: ['main.tex'],
- users: ['user1'],
- startTs: 0,
- fromV: 4,
- }),
- ])
- })
- describe('label on an update', function () {
- const label = {
- id: 'mock-id',
- comment: 'an example comment',
- version: 5,
- }
- setupChunks([
- [
- makeUpdate({ startTs: 0, v: 3 }),
- makeUpdate({ startTs: 20, v: 4 }),
- makeUpdate({ startTs: 40, v: 5 }),
- makeUpdate({ startTs: 60, v: 6 }),
- ],
- ])
- setupLabels([label])
- expectSummaries('should split the updates at the label', {}, [
- makeSummary({ startTs: 40, endTs: 70, fromV: 5, toV: 7 }),
- makeSummary({
- startTs: 0,
- endTs: 30,
- fromV: 3,
- toV: 5,
- labels: [label],
- }),
- ])
- })
- describe('updates with origin', function () {
- setupChunks([
- [
- makeUpdate({ startTs: 0, v: 1 }),
- makeUpdate({ startTs: 10, v: 2 }),
- makeUpdate({
- startTs: 20,
- v: 3,
- origin: { kind: 'history-resync' },
- }),
- makeUpdate({
- startTs: 30,
- v: 4,
- origin: { kind: 'history-resync' },
- }),
- makeUpdate({ startTs: 40, v: 5 }),
- makeUpdate({ startTs: 50, v: 6 }),
- ],
- ])
- expectSummaries(
- 'should split the updates where the origin appears or disappears',
- {},
- [
- makeSummary({ startTs: 40, endTs: 60, fromV: 5, toV: 7 }),
- makeSummary({
- startTs: 20,
- endTs: 40,
- fromV: 3,
- toV: 5,
- origin: { kind: 'history-resync' },
- }),
- makeSummary({ startTs: 0, endTs: 20, fromV: 1, toV: 3 }),
- ]
- )
- })
- describe('updates with different origins', function () {
- setupChunks([
- [
- makeUpdate({ startTs: 0, v: 1, origin: { kind: 'origin-a' } }),
- makeUpdate({ startTs: 10, v: 2, origin: { kind: 'origin-a' } }),
- makeUpdate({ startTs: 20, v: 3, origin: { kind: 'origin-b' } }),
- makeUpdate({ startTs: 30, v: 4, origin: { kind: 'origin-b' } }),
- ],
- ])
- expectSummaries(
- 'should split the updates when the origin kind changes',
- {},
- [
- makeSummary({
- startTs: 20,
- endTs: 40,
- fromV: 3,
- toV: 5,
- origin: { kind: 'origin-b' },
- }),
- makeSummary({
- startTs: 0,
- endTs: 20,
- fromV: 1,
- toV: 3,
- origin: { kind: 'origin-a' },
- }),
- ]
- )
- })
- describe('empty updates', function () {
- setupChunks([
- [
- makeUpdate({ startTs: 0, v: 1, pathnames: ['main.tex'] }),
- makeUpdate({ startTs: 10, v: 2, pathnames: [] }),
- makeUpdate({ startTs: 20, v: 3, pathnames: ['main.tex'] }),
- makeUpdate({ startTs: 30, v: 4, pathnames: [] }),
- makeUpdate({ startTs: 40, v: 5, pathnames: [] }),
- ],
- [
- makeUpdate({ startTs: 50, v: 6, pathnames: [] }),
- makeUpdate({ startTs: LATER, v: 7, pathnames: [] }),
- makeUpdate({ startTs: LATER + 10, v: 8, pathnames: ['main.tex'] }),
- makeUpdate({ startTs: LATER + 20, v: 9, pathnames: ['main.tex'] }),
- makeUpdate({ startTs: LATER + 30, v: 10, pathnames: [] }),
- ],
- ])
- expectSummaries('should skip empty updates', {}, [
- makeSummary({
- startTs: LATER + 10,
- endTs: LATER + 30,
- fromV: 8,
- toV: 11,
- }),
- makeSummary({ startTs: 0, endTs: 30, fromV: 1, toV: 8 }),
- ])
- })
- describe('history resync updates', function () {
- setupChunks([
- [
- makeUpdate({
- startTs: 0,
- v: 1,
- origin: { kind: 'history-resync' },
- projectOps: [{ add: { pathname: 'file1.tex' } }],
- pathnames: [],
- }),
- makeUpdate({
- startTs: 20,
- v: 2,
- origin: { kind: 'history-resync' },
- projectOps: [
- { add: { pathname: 'file2.tex' } },
- { add: { pathname: 'file3.tex' } },
- ],
- pathnames: [],
- }),
- makeUpdate({
- startTs: 40,
- v: 3,
- origin: { kind: 'history-resync' },
- projectOps: [{ add: { pathname: 'file4.tex' } }],
- pathnames: [],
- }),
- makeUpdate({
- startTs: 60,
- v: 4,
- origin: { kind: 'history-resync' },
- projectOps: [],
- pathnames: ['file1.tex', 'file2.tex', 'file5.tex'],
- }),
- makeUpdate({
- startTs: 80,
- v: 5,
- origin: { kind: 'history-resync' },
- projectOps: [],
- pathnames: ['file4.tex'],
- }),
- makeUpdate({ startTs: 100, v: 6, pathnames: ['file1.tex'] }),
- ],
- ])
- expectSummaries('should merge creates and edits', {}, [
- makeSummary({
- startTs: 100,
- endTs: 110,
- fromV: 6,
- toV: 7,
- pathnames: ['file1.tex'],
- }),
- makeSummary({
- startTs: 0,
- endTs: 90,
- fromV: 1,
- toV: 6,
- origin: { kind: 'history-resync' },
- pathnames: ['file5.tex'],
- projectOps: [
- { add: { pathname: 'file4.tex' }, atV: 3 },
- { add: { pathname: 'file2.tex' }, atV: 2 },
- { add: { pathname: 'file3.tex' }, atV: 2 },
- { add: { pathname: 'file1.tex' }, atV: 1 },
- ],
- }),
- ])
- })
- })
- })
- })
- /**
- * Set up mocks as if the project had a number of chunks.
- *
- * Each parameter represents a chunk and the value of the parameter is the list
- * of updates in that chunk.
- */
- function setupChunks(updatesByChunk) {
- beforeEach('set up chunks', function () {
- let startVersion = 0
- for (let i = 0; i < updatesByChunk.length; i++) {
- const updates = updatesByChunk[i]
- const chunk = { chunk: { startVersion } }
- // Find the chunk by any update version
- for (const update of updates) {
- this.HistoryStoreManager.getChunkAtVersion
- .withArgs(this.projectId, this.historyId, update.v)
- .yields(null, chunk)
- startVersion = update.v
- }
- if (i === updatesByChunk.length - 1) {
- this.HistoryStoreManager.getMostRecentChunk
- .withArgs(this.projectId, this.historyId)
- .yields(null, chunk)
- }
- this.ChunkTranslator.convertToSummarizedUpdates
- .withArgs(chunk)
- .yields(null, updates)
- }
- })
- }
- function setupLabels(labels) {
- beforeEach('set up labels', function () {
- this.LabelsManager.getLabels.withArgs(this.projectId).yields(null, labels)
- })
- }
- function expectSummaries(description, options, expectedSummaries) {
- it(`${description}`, function (done) {
- this.SummarizedUpdatesManager.getSummarizedProjectUpdates(
- this.projectId,
- options,
- (err, summaries) => {
- if (err) {
- return done(err)
- }
- // The order of the users array is not significant
- for (const summary of summaries) {
- summary.meta.users.sort()
- }
- for (const summary of expectedSummaries) {
- summary.meta.users.sort()
- }
- expect(summaries).to.deep.equal(expectedSummaries)
- done()
- }
- )
- })
- }
- function makeUpdate(options = {}) {
- const {
- pathnames = ['main.tex'],
- users = ['user1'],
- projectOps = [],
- startTs = 0,
- endTs = startTs + 10,
- v = 1,
- origin,
- } = options
- const update = {
- pathnames,
- project_ops: projectOps,
- meta: { users, start_ts: startTs, end_ts: endTs },
- v,
- }
- if (origin) {
- update.meta.origin = origin
- }
- return update
- }
- function makeSummary(options = {}) {
- const {
- pathnames = ['main.tex'],
- users = ['user1'],
- startTs = 0,
- endTs = startTs + 10,
- fromV = 1,
- toV = fromV + 1,
- labels = [],
- projectOps = [],
- origin,
- } = options
- const summary = {
- pathnames: new Set(pathnames),
- meta: {
- users,
- start_ts: startTs,
- end_ts: endTs,
- },
- fromV,
- toV,
- labels,
- project_ops: projectOps,
- }
- if (origin) {
- summary.meta.origin = origin
- }
- return summary
- }
|