diff --git a/libraries/overleaf-editor-core/test/unit/text_operation.test.js b/libraries/overleaf-editor-core/test/unit/text_operation.test.js index d58ebf838e..2d4666c67f 100644 --- a/libraries/overleaf-editor-core/test/unit/text_operation.test.js +++ b/libraries/overleaf-editor-core/test/unit/text_operation.test.js @@ -444,10 +444,71 @@ describe('TextOperation', function () { c: c.toJSON(), }) - expect(ab_c_file.toRaw()).to.deep.equal(a_bc_file.toRaw(), fuzzingError) + // See 'compose associativity does not handle timestamps' test below + // for why we ignore timestamps here. + expect(stripTrackedChangeTimestamps(ab_c_file.toRaw())).to.deep.equal( + stripTrackedChangeTimestamps(a_bc_file.toRaw()), + fuzzingError + ) }) ) + it('compose associativity does not handle timestamps', function () { + const str = 'AB' + const comments = [] + + // a tracks both chars as a delete @2023 + const a = TextOperation.fromJSON({ + textOperation: [ + { + r: 2, + tracking: { + type: 'delete', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ], + }) + // b re-tracks the first char as a delete @2022 (same user), leaving the + // second char as an untracked retain (still @2023 from a, and adjacent) + const b = TextOperation.fromJSON({ + textOperation: [ + { + r: 1, + tracking: { + type: 'delete', + userId: 'user1', + ts: '2022-01-01T00:00:00.000Z', + }, + }, + 1, + ], + }) + // c inserts between the two chars, breaking their adjacency + const c = TextOperation.fromJSON({ textOperation: [1, 'X', 1] }) + + const ab_c = a.compose(b).compose(c) + const a_bc = a.compose(b.compose(c)) + + const ab_c_file = new StringFileData(str, comments) + ab_c.apply(ab_c_file) + + const a_bc_file = new StringFileData(str, comments) + a_bc.apply(a_bc_file) + + // The two composition orders diverge, but only on the tracked-change + // timestamp: (a∘b)∘c merges char 1 down to @2022 before c splits it, + // while a∘(b∘c) leaves char 1 at @2023. + // + // If we fix the associativity for timestamps, feel free to update the + // assertions below + expect(ab_c_file.toRaw()).to.not.deep.equal(a_bc_file.toRaw()) + expect(stripTrackedChangeTimestamps(ab_c_file.toRaw())).to.deep.equal( + stripTrackedChangeTimestamps(a_bc_file.toRaw()) + ) + }) + it('composes two operations with comments', function () { expect( compose( @@ -1026,3 +1087,19 @@ function transform(fileData, a, b) { return aFileData.toRaw() } + +/** + * @param {import('../../lib/types').StringFileRawData} raw + */ +function stripTrackedChangeTimestamps(raw) { + if (!raw.trackedChanges) { + return raw + } + return { + ...raw, + trackedChanges: raw.trackedChanges.map(change => { + const { ts, ...tracking } = change.tracking + return { ...change, tracking } + }), + } +}