Эх сурвалжийг харах

Merge pull request #16807 from overleaf/mj-tracked-changes-history

[overleaf-editor-core] Add TrackedChangeList to StringFileData

GitOrigin-RevId: b2abce4860c5be6189beafca575de04e880b08f5
Mathias Jakobsen 2 жил өмнө
parent
commit
ad0a8be0af

+ 17 - 0
libraries/overleaf-editor-core/lib/file_data/range.js

@@ -170,6 +170,23 @@ class Range {
   static fromRaw(raw) {
     return new Range(raw.pos, raw.length)
   }
+
+  /**
+   * Splits a range into two ranges, at a given cursor
+   * @param {number} cursor
+   * @returns {[Range, Range]}
+   */
+  splitAt(cursor) {
+    if (!this.containsCursor(cursor)) {
+      throw new Error('The cursor must be contained in the range')
+    }
+    const rangeUpToCursor = new Range(this.pos, cursor - this.pos)
+    const rangeAfterCursor = new Range(
+      cursor,
+      this.length - rangeUpToCursor.length
+    )
+    return [rangeUpToCursor, rangeAfterCursor]
+  }
 }
 
 module.exports = Range

+ 10 - 2
libraries/overleaf-editor-core/lib/file_data/string_file_data.js

@@ -5,24 +5,28 @@ const assert = require('check-types').assert
 
 const FileData = require('./')
 const CommentList = require('./comment_list')
+const TrackedChangeList = require('./tracked_change_list')
 
 /**
  * @typedef {import("../types").StringFileRawData} StringFileRawData
  * @typedef {import("../operation/edit_operation")} EditOperation
  * @typedef {import("../types").BlobStore} BlobStore
  * @typedef {import("../types").CommentRawData} CommentRawData
+ * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
  */
 
 class StringFileData extends FileData {
   /**
    * @param {string} content
-   * @param {CommentRawData[]} [rawComments]
+   * @param {CommentRawData[] | undefined} [rawComments]
+   * @param {TrackedChangeRawData[] | undefined} [rawTrackedChanges]
    */
-  constructor(content, rawComments = []) {
+  constructor(content, rawComments = [], rawTrackedChanges = []) {
     super()
     assert.string(content)
     this.content = content
     this.comments = CommentList.fromRaw(rawComments)
+    this.trackedChanges = TrackedChangeList.fromRaw(rawTrackedChanges)
   }
 
   /**
@@ -45,6 +49,10 @@ class StringFileData extends FileData {
       raw.comments = comments
     }
 
+    if (this.trackedChanges.length) {
+      raw.trackedChanges = this.trackedChanges.toRaw()
+    }
+
     return raw
   }
 

+ 77 - 0
libraries/overleaf-editor-core/lib/file_data/tracked_change.js

@@ -0,0 +1,77 @@
+// @ts-check
+const Range = require('./range')
+const TrackingProps = require('./tracking_props')
+
+/**
+ * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
+ */
+
+class TrackedChange {
+  /**
+   *
+   * @param {Range} range
+   * @param {TrackingProps} tracking
+   */
+  constructor(range, tracking) {
+    this.range = range
+    this.tracking = tracking
+  }
+
+  /**
+   *
+   * @param {TrackedChangeRawData} raw
+   * @returns {TrackedChange}
+   */
+  static fromRaw(raw) {
+    return new TrackedChange(
+      Range.fromRaw(raw.range),
+      TrackingProps.fromRaw(raw.tracking)
+    )
+  }
+
+  /**
+   * @returns {TrackedChangeRawData}
+   */
+  toRaw() {
+    return {
+      range: this.range.toRaw(),
+      tracking: this.tracking.toRaw(),
+    }
+  }
+
+  /**
+   * Checks whether the tracked change can be merged with another
+   * @param {TrackedChange} other
+   * @returns {boolean}
+   */
+  canMerge(other) {
+    if (!(other instanceof TrackedChange)) {
+      return false
+    }
+    return (
+      this.tracking.type === other.tracking.type &&
+      this.tracking.userId === other.tracking.userId &&
+      this.range.touches(other.range) &&
+      this.range.canMerge(other.range)
+    )
+  }
+
+  /**
+   * Merges another tracked change into this, updating the range and tracking
+   * timestamp
+   * @param {TrackedChange} other
+   * @returns {void}
+   */
+  merge(other) {
+    if (!this.canMerge(other)) {
+      throw new Error('Cannot merge tracked changes')
+    }
+    this.range.merge(other.range)
+    this.tracking.ts =
+      this.tracking.ts.getTime() > other.tracking.ts.getTime()
+        ? this.tracking.ts
+        : other.tracking.ts
+  }
+}
+
+module.exports = TrackedChange

+ 224 - 0
libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js

@@ -0,0 +1,224 @@
+// @ts-check
+const Range = require('./range')
+const TrackedChange = require('./tracked_change')
+
+/**
+ * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
+ * @typedef {import("../file_data/tracking_props")} TrackingProps
+ */
+
+class TrackedChangeList {
+  /**
+   *
+   * @param {TrackedChange[]} trackedChanges
+   */
+  constructor(trackedChanges) {
+    this.trackedChanges = trackedChanges
+  }
+
+  /**
+   *
+   * @param {TrackedChangeRawData[]} raw
+   * @returns {TrackedChangeList}
+   */
+  static fromRaw(raw) {
+    return new TrackedChangeList(raw.map(TrackedChange.fromRaw))
+  }
+
+  /**
+   * Converts the tracked changes to a raw object
+   * @returns {TrackedChangeRawData[]}
+   */
+  toRaw() {
+    return this.trackedChanges.map(change => change.toRaw())
+  }
+
+  get length() {
+    return this.trackedChanges.length
+  }
+
+  /**
+   * Returns the tracked changes that are fully included in the range
+   * @param {Range} range
+   * @returns {TrackedChange[]}
+   */
+  inRange(range) {
+    return this.trackedChanges.filter(change => range.contains(change.range))
+  }
+
+  /**
+   * Removes the tracked changes that are fully included in the range
+   * @param {Range} range
+   */
+  removeInRange(range) {
+    this.trackedChanges = this.trackedChanges.filter(
+      change => !range.contains(change.range)
+    )
+  }
+
+  /**
+   * Adds a tracked change to the list
+   * @param {TrackedChange} trackedChange
+   */
+  add(trackedChange) {
+    this.trackedChanges.push(trackedChange)
+    this._mergeRanges()
+  }
+
+  /**
+   * Collapses consecutive (and compatible) ranges
+   * @returns {void}
+   */
+  _mergeRanges() {
+    if (this.trackedChanges.length < 2) {
+      return
+    }
+    // ranges are non-overlapping so we can sort based on their first indices
+    this.trackedChanges.sort((a, b) => a.range.start - b.range.start)
+    const newTrackedChanges = [this.trackedChanges[0]]
+    for (let i = 1; i < this.trackedChanges.length; i++) {
+      const last = newTrackedChanges[newTrackedChanges.length - 1]
+      const current = this.trackedChanges[i]
+      if (last.canMerge(current)) {
+        last.merge(current)
+      } else {
+        newTrackedChanges.push(current)
+      }
+    }
+    this.trackedChanges = newTrackedChanges
+  }
+
+  /**
+   *
+   * @param {number} cursor
+   * @param {string} insertedText
+   * @param {{tracking?: TrackingProps}} opts
+   */
+  applyInsert(cursor, insertedText, opts = {}) {
+    const newTrackedChanges = []
+    for (const trackedChange of this.trackedChanges) {
+      if (
+        // If the cursor is before or at the insertion point, we need to move
+        // the tracked change
+        trackedChange.range.startIsAfter(cursor) ||
+        cursor === trackedChange.range.start
+      ) {
+        trackedChange.range.moveBy(insertedText.length)
+        newTrackedChanges.push(trackedChange)
+      } else if (cursor === trackedChange.range.end) {
+        // The insertion is at the end of the tracked change. So we don't need
+        // to move it.
+        newTrackedChanges.push(trackedChange)
+      } else if (trackedChange.range.containsCursor(cursor)) {
+        // If the tracked change is in the inserted text, we need to expand it
+        // split in three chunks. The middle one is added if it is a tracked insertion
+        const [firstRange, , thirdRange] = trackedChange.range.insertAt(
+          cursor,
+          insertedText.length
+        )
+        const firstPart = new TrackedChange(
+          firstRange,
+          trackedChange.tracking.clone()
+        )
+        newTrackedChanges.push(firstPart)
+        // second part will be added at the end if it is a tracked insertion
+        const thirdPart = new TrackedChange(
+          thirdRange,
+          trackedChange.tracking.clone()
+        )
+        newTrackedChanges.push(thirdPart)
+      } else {
+        newTrackedChanges.push(trackedChange)
+      }
+    }
+
+    if (opts.tracking) {
+      // This is a new tracked change
+      const newTrackedChange = new TrackedChange(
+        new Range(cursor, insertedText.length),
+        opts.tracking
+      )
+      newTrackedChanges.push(newTrackedChange)
+    }
+    this.trackedChanges = newTrackedChanges
+    this._mergeRanges()
+  }
+
+  /**
+   *
+   * @param {number} cursor
+   * @param {number} length
+   */
+  applyDelete(cursor, length) {
+    const newTrackedChanges = []
+    for (const trackedChange of this.trackedChanges) {
+      const deletedRange = new Range(cursor, length)
+      // If the tracked change is after the deletion, we need to move it
+      if (deletedRange.contains(trackedChange.range)) {
+        continue
+      } else if (deletedRange.overlaps(trackedChange.range)) {
+        trackedChange.range.subtract(deletedRange)
+        newTrackedChanges.push(trackedChange)
+      } else if (trackedChange.range.startIsAfter(cursor)) {
+        trackedChange.range.pos -= length
+        newTrackedChanges.push(trackedChange)
+      } else {
+        newTrackedChanges.push(trackedChange)
+      }
+    }
+    this.trackedChanges = newTrackedChanges
+    this._mergeRanges()
+  }
+
+  /**
+   * @param {number} cursor
+   * @param {number} length
+   * @param {{tracking?: TrackingProps}} opts
+   */
+  applyRetain(cursor, length, opts = {}) {
+    // If there's no tracking info, leave everything as-is
+    if (!opts.tracking) {
+      return
+    }
+    const newTrackedChanges = []
+    const retainedRange = new Range(cursor, length)
+    for (const trackedChange of this.trackedChanges) {
+      if (retainedRange.contains(trackedChange.range)) {
+        // Remove the range
+      } else if (retainedRange.overlaps(trackedChange.range)) {
+        if (trackedChange.range.contains(retainedRange)) {
+          const [leftRange, rightRange] = trackedChange.range.splitAt(cursor)
+          rightRange.pos += length
+          rightRange.length -= length
+          newTrackedChanges.push(
+            new TrackedChange(leftRange, trackedChange.tracking.clone())
+          )
+          newTrackedChanges.push(
+            new TrackedChange(rightRange, trackedChange.tracking.clone())
+          )
+        } else if (retainedRange.start <= trackedChange.range.start) {
+          // overlaps to the left
+          const [, reducedRange] = trackedChange.range.splitAt(
+            retainedRange.end
+          )
+          trackedChange.range = reducedRange
+          newTrackedChanges.push(trackedChange)
+        } else {
+          // overlaps to the right
+          const [reducedRange] = trackedChange.range.splitAt(cursor)
+          trackedChange.range = reducedRange
+          newTrackedChanges.push(trackedChange)
+        }
+      }
+    }
+    if (opts.tracking?.type === 'delete' || opts.tracking?.type === 'insert') {
+      // This is a new tracked change
+      const newTrackedChange = new TrackedChange(retainedRange, opts.tracking)
+      newTrackedChanges.push(newTrackedChange)
+    }
+    this.trackedChanges = newTrackedChanges
+    this._mergeRanges()
+  }
+}
+
+module.exports = TrackedChangeList

+ 52 - 0
libraries/overleaf-editor-core/lib/file_data/tracking_props.js

@@ -0,0 +1,52 @@
+// @ts-check
+/**
+ * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
+ */
+
+class TrackingProps {
+  /**
+   *
+   * @param {'insert' | 'delete' | 'none'} type
+   * @param {string} userId
+   * @param {Date} ts
+   */
+  constructor(type, userId, ts) {
+    /**
+     * @readonly
+     * @type {'insert' | 'delete' | 'none'}
+     */
+    this.type = type
+    /**
+     * @readonly
+     * @type {string}
+     */
+    this.userId = userId
+    /**
+     * @type {Date}
+     */
+    this.ts = ts
+  }
+
+  /**
+   *
+   * @param {TrackedChangeRawData['tracking']} raw
+   * @returns
+   */
+  static fromRaw(raw) {
+    return new TrackingProps(raw.type, raw.userId, new Date(raw.ts))
+  }
+
+  toRaw() {
+    return {
+      type: this.type,
+      userId: this.userId,
+      ts: this.ts.toISOString(),
+    }
+  }
+
+  clone() {
+    return new TrackingProps(this.type, this.userId, this.ts)
+  }
+}
+
+module.exports = TrackingProps

+ 17 - 4
libraries/overleaf-editor-core/lib/types.ts

@@ -5,15 +5,28 @@ export type BlobStore = {
   putString(content: string): Promise<Blob>
 }
 
+type Range = {
+  pos: number
+  length: number
+}
+
 export type CommentRawData = {
   id: string
-  ranges: {
-    pos: number
-    length: number
-  }[]
+  ranges: Range[]
   resolved?: boolean
 }
 
+export type TrackedChangeRawData = {
+  range: Range
+  tracking: TrackingPropsRawData
+}
+
+export type TrackingPropsRawData = {
+  type: 'insert' | 'delete' | 'none'
+  userId: string
+  ts: string
+}
+
 export type StringFileRawData = {
   content: string
   comments?: CommentRawData[]

+ 86 - 0
libraries/overleaf-editor-core/test/range.test.js

@@ -342,4 +342,90 @@ describe('Range', function () {
     expect(from5to14.start).to.eql(8)
     expect(from5to14.end).to.eql(18)
   })
+
+  describe('splitAt', function () {
+    it('should split at the start', function () {
+      const range = new Range(5, 10)
+      const [left, right] = range.splitAt(5)
+      expect(left.isEmpty()).to.be.true
+      expect(right.start).to.eql(5)
+      expect(right.end).to.eql(15)
+    })
+
+    it('should not split before the start', function () {
+      const range = new Range(5, 10)
+      expect(() => range.splitAt(4)).to.throw()
+    })
+
+    it('should split at last cursor in range', function () {
+      const range = new Range(5, 10)
+      const [left, right] = range.splitAt(14)
+      expect(left.start).to.equal(5)
+      expect(left.end).to.equal(14)
+      expect(right.start).to.equal(14)
+      expect(right.end).to.equal(15)
+    })
+
+    it('should not split after the end', function () {
+      const range = new Range(5, 10)
+      expect(() => range.splitAt(16)).to.throw()
+    })
+
+    it('should split at end', function () {
+      const range = new Range(5, 10)
+      const [left, right] = range.splitAt(15)
+      expect(left.start).to.equal(5)
+      expect(left.end).to.equal(15)
+      expect(right.start).to.equal(15)
+      expect(right.end).to.equal(15)
+    })
+
+    it('should split in the middle', function () {
+      const range = new Range(5, 10)
+      const [left, right] = range.splitAt(10)
+      expect(left.start).to.equal(5)
+      expect(left.end).to.equal(10)
+      expect(right.start).to.equal(10)
+      expect(right.end).to.equal(15)
+    })
+  })
+
+  describe('insertAt', function () {
+    it('should insert at the start', function () {
+      const range = new Range(5, 10)
+      const [left, inserted, right] = range.insertAt(5, 3)
+      expect(left.isEmpty()).to.be.true
+      expect(inserted.start).to.eql(5)
+      expect(inserted.end).to.eql(8)
+      expect(right.start).to.eql(8)
+      expect(right.end).to.eql(18)
+    })
+
+    it('should insert at the end', function () {
+      const range = new Range(5, 10)
+      const [left, inserted, right] = range.insertAt(15, 3)
+      expect(left.start).to.eql(5)
+      expect(left.end).to.eql(15)
+      expect(inserted.start).to.eql(15)
+      expect(inserted.end).to.eql(18)
+      expect(right.isEmpty()).to.be.true
+    })
+
+    it('should insert in the middle', function () {
+      const range = new Range(5, 10)
+      const [left, inserted, right] = range.insertAt(10, 3)
+      expect(left.start).to.eql(5)
+      expect(left.end).to.eql(10)
+      expect(inserted.start).to.eql(10)
+      expect(inserted.end).to.eql(13)
+      expect(right.start).to.eql(13)
+      expect(right.end).to.eql(18)
+    })
+
+    it('should throw if cursor is out of range', function () {
+      const range = new Range(5, 10)
+      expect(() => range.insertAt(4, 3)).to.throw()
+      expect(() => range.insertAt(16, 3)).to.throw()
+    })
+  })
 })

+ 55 - 0
libraries/overleaf-editor-core/test/tracked_change.test.js

@@ -0,0 +1,55 @@
+// @ts-check
+const TrackedChange = require('../lib/file_data/tracked_change')
+const Range = require('../lib/file_data/range')
+const TrackingProps = require('../lib/file_data/tracking_props')
+const { expect } = require('chai')
+
+describe('TrackedChange', function () {
+  it('should survive serialization', function () {
+    const trackedChange = new TrackedChange(
+      new Range(1, 2),
+      new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
+    )
+    const newTrackedChange = TrackedChange.fromRaw(trackedChange.toRaw())
+    expect(newTrackedChange).to.be.instanceOf(TrackedChange)
+    expect(newTrackedChange).to.deep.equal(trackedChange)
+  })
+
+  it('can be created from a raw object', function () {
+    const trackedChange = TrackedChange.fromRaw({
+      range: { pos: 1, length: 2 },
+      tracking: {
+        type: 'insert',
+        userId: 'user1',
+        ts: '2024-01-01T00:00:00.000Z',
+      },
+    })
+    expect(trackedChange).to.be.instanceOf(TrackedChange)
+    expect(trackedChange).to.deep.equal(
+      new TrackedChange(
+        new Range(1, 2),
+        new TrackingProps(
+          'insert',
+          'user1',
+          new Date('2024-01-01T00:00:00.000Z')
+        )
+      )
+    )
+  })
+
+  it('can be serialized to a raw object', function () {
+    const change = new TrackedChange(
+      new Range(1, 2),
+      new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
+    )
+    expect(change).to.be.instanceOf(TrackedChange)
+    expect(change.toRaw()).to.deep.equal({
+      range: { pos: 1, length: 2 },
+      tracking: {
+        type: 'insert',
+        userId: 'user1',
+        ts: '2024-01-01T00:00:00.000Z',
+      },
+    })
+  })
+})

+ 845 - 0
libraries/overleaf-editor-core/test/tracked_change_list.test.js

@@ -0,0 +1,845 @@
+// @ts-check
+const TrackedChangeList = require('../lib/file_data/tracked_change_list')
+const TrackingProps = require('../lib/file_data/tracking_props')
+const { expect } = require('chai')
+/** @typedef {import('../lib/types').TrackedChangeRawData} TrackedChangeRawData */
+
+describe('TrackedChangeList', function () {
+  describe('applyInsert', function () {
+    describe('with same author', function () {
+      it('should merge consecutive tracked changes and use the latest timestamp', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(3, 'foo', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(1)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should extend tracked changes when inserting in the middle', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 10 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(5, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(1)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 16 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should merge two tracked changes starting at the same position', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(0, 'foo', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(1)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should not extend range when there is a gap between the ranges', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(4, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 4, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should not merge tracked changes if there is a space between them', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 5, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(4, 'foo', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 4, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 8, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+    })
+
+    describe('with different authors', function () {
+      it('should not merge consecutive tracked changes', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(3, 'foo', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 3, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should not merge tracked changes at same position', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(0, 'foo', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 3, length: 3 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should insert tracked changes in the middle of a tracked range', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 10 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(5, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(3)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 5, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 11, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should insert tracked changes at the end of a tracked range', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(5, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 5, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should split a track range when inserting at last contained cursor', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 0, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(4, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(3)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 0, length: 4 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 4, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 10, length: 1 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+
+      it('should insert a new range if inserted just before the first cursor of a tracked range', function () {
+        const trackedChanges = TrackedChangeList.fromRaw([
+          {
+            range: { pos: 5, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+        trackedChanges.applyInsert(5, 'foobar', {
+          tracking: TrackingProps.fromRaw({
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          }),
+        })
+        expect(trackedChanges.trackedChanges.length).to.equal(2)
+        expect(trackedChanges.toRaw()).to.deep.equal([
+          {
+            range: { pos: 5, length: 6 },
+            tracking: {
+              type: 'insert',
+              userId: 'user2',
+              ts: '2024-01-01T00:00:00.000Z',
+            },
+          },
+          {
+            range: { pos: 11, length: 5 },
+            tracking: {
+              type: 'insert',
+              userId: 'user1',
+              ts: '2023-01-01T00:00:00.000Z',
+            },
+          },
+        ])
+      })
+    })
+  })
+
+  describe('applyDelete', function () {
+    it('should shrink tracked changes', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyDelete(5, 2)
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 8 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should delete tracked changes when the whole range is deleted', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyDelete(0, 10)
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+
+    it('should delete tracked changes when more than the whole range is deleted', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 5, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyDelete(0, 25)
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+
+    it('should shrink the tracked change from start with overlap', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyDelete(1, 9)
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 1 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should shrink the tracked change from end with overlap', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyDelete(0, 9)
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 1 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+  })
+
+  describe('fromRaw & toRaw', function () {
+    it('should survive serialization', function () {
+      /** @type {TrackedChangeRawData[]} */
+      const initialRaw = [
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          },
+        },
+      ]
+
+      const trackedChanges = TrackedChangeList.fromRaw(initialRaw)
+      const raw = trackedChanges.toRaw()
+      const newTrackedChanges = TrackedChangeList.fromRaw(raw)
+
+      expect(newTrackedChanges).to.deep.equal(trackedChanges)
+      expect(raw).to.deep.equal(initialRaw)
+    })
+  })
+
+  describe('applyRetain', function () {
+    it('should add tracking information to an untracked range', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([])
+      trackedChanges.applyRetain(0, 10, {
+        tracking: TrackingProps.fromRaw({
+          type: 'insert',
+          userId: 'user1',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should shrink a tracked range to make room for retained operation', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 3, length: 7 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 5, {
+        tracking: TrackingProps.fromRaw({
+          type: 'insert',
+          userId: 'user2',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(2)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 5 },
+          tracking: {
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          },
+        },
+        {
+          range: { pos: 5, length: 5 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should break up a tracked range to make room for retained operation', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(5, 1, {
+        tracking: TrackingProps.fromRaw({
+          type: 'insert',
+          userId: 'user2',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(3)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 5 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+        {
+          range: { pos: 5, length: 1 },
+          tracking: {
+            type: 'insert',
+            userId: 'user2',
+            ts: '2024-01-01T00:00:00.000Z',
+          },
+        },
+        {
+          range: { pos: 6, length: 4 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should update the timestamp of a tracked range', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(1, 12, {
+        tracking: TrackingProps.fromRaw({
+          type: 'insert',
+          userId: 'user1',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 13 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2024-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should leave ignore a retain operation with no tracking info', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 10)
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should leave not break up a tracked change for a retain with no tracking info', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(4, 1)
+      expect(trackedChanges.trackedChanges.length).to.equal(1)
+      expect(trackedChanges.toRaw()).to.deep.equal([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+    })
+
+    it('should delete a tracked change which is being resolved', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 10, {
+        tracking: TrackingProps.fromRaw({
+          type: 'none',
+          userId: 'user1',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+
+    it('should delete a tracked change which is being resolved by other user', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'insert',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 10, {
+        tracking: TrackingProps.fromRaw({
+          type: 'none',
+          userId: 'user2',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+
+    it('should delete a tracked change which is being rejected', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'delete',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 10, {
+        tracking: TrackingProps.fromRaw({
+          type: 'none',
+          userId: 'user1',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+
+    it('should delete a tracked change which is being rejected by other user', function () {
+      const trackedChanges = TrackedChangeList.fromRaw([
+        {
+          range: { pos: 0, length: 10 },
+          tracking: {
+            type: 'delete',
+            userId: 'user1',
+            ts: '2023-01-01T00:00:00.000Z',
+          },
+        },
+      ])
+      trackedChanges.applyRetain(0, 10, {
+        tracking: TrackingProps.fromRaw({
+          type: 'none',
+          userId: 'user2',
+          ts: '2024-01-01T00:00:00.000Z',
+        }),
+      })
+      expect(trackedChanges.trackedChanges.length).to.equal(0)
+      expect(trackedChanges.toRaw()).to.deep.equal([])
+    })
+  })
+})