mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #16807 from overleaf/mj-tracked-changes-history
[overleaf-editor-core] Add TrackedChangeList to StringFileData GitOrigin-RevId: b2abce4860c5be6189beafca575de04e880b08f5
This commit is contained in:
parent
09ff9526b8
commit
ad0a8be0af
9 changed files with 1383 additions and 6 deletions
|
@ -170,6 +170,23 @@ class Range {
|
||||||
static fromRaw(raw) {
|
static fromRaw(raw) {
|
||||||
return new Range(raw.pos, raw.length)
|
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
|
module.exports = Range
|
||||||
|
|
|
@ -5,24 +5,28 @@ const assert = require('check-types').assert
|
||||||
|
|
||||||
const FileData = require('./')
|
const FileData = require('./')
|
||||||
const CommentList = require('./comment_list')
|
const CommentList = require('./comment_list')
|
||||||
|
const TrackedChangeList = require('./tracked_change_list')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("../types").StringFileRawData} StringFileRawData
|
* @typedef {import("../types").StringFileRawData} StringFileRawData
|
||||||
* @typedef {import("../operation/edit_operation")} EditOperation
|
* @typedef {import("../operation/edit_operation")} EditOperation
|
||||||
* @typedef {import("../types").BlobStore} BlobStore
|
* @typedef {import("../types").BlobStore} BlobStore
|
||||||
* @typedef {import("../types").CommentRawData} CommentRawData
|
* @typedef {import("../types").CommentRawData} CommentRawData
|
||||||
|
* @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class StringFileData extends FileData {
|
class StringFileData extends FileData {
|
||||||
/**
|
/**
|
||||||
* @param {string} content
|
* @param {string} content
|
||||||
* @param {CommentRawData[]} [rawComments]
|
* @param {CommentRawData[] | undefined} [rawComments]
|
||||||
|
* @param {TrackedChangeRawData[] | undefined} [rawTrackedChanges]
|
||||||
*/
|
*/
|
||||||
constructor(content, rawComments = []) {
|
constructor(content, rawComments = [], rawTrackedChanges = []) {
|
||||||
super()
|
super()
|
||||||
assert.string(content)
|
assert.string(content)
|
||||||
this.content = content
|
this.content = content
|
||||||
this.comments = CommentList.fromRaw(rawComments)
|
this.comments = CommentList.fromRaw(rawComments)
|
||||||
|
this.trackedChanges = TrackedChangeList.fromRaw(rawTrackedChanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,6 +49,10 @@ class StringFileData extends FileData {
|
||||||
raw.comments = comments
|
raw.comments = comments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.trackedChanges.length) {
|
||||||
|
raw.trackedChanges = this.trackedChanges.toRaw()
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -5,15 +5,28 @@ export type BlobStore = {
|
||||||
putString(content: string): Promise<Blob>
|
putString(content: string): Promise<Blob>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Range = {
|
||||||
|
pos: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
export type CommentRawData = {
|
export type CommentRawData = {
|
||||||
id: string
|
id: string
|
||||||
ranges: {
|
ranges: Range[]
|
||||||
pos: number
|
|
||||||
length: number
|
|
||||||
}[]
|
|
||||||
resolved?: boolean
|
resolved?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrackedChangeRawData = {
|
||||||
|
range: Range
|
||||||
|
tracking: TrackingPropsRawData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackingPropsRawData = {
|
||||||
|
type: 'insert' | 'delete' | 'none'
|
||||||
|
userId: string
|
||||||
|
ts: string
|
||||||
|
}
|
||||||
|
|
||||||
export type StringFileRawData = {
|
export type StringFileRawData = {
|
||||||
content: string
|
content: string
|
||||||
comments?: CommentRawData[]
|
comments?: CommentRawData[]
|
||||||
|
|
|
@ -342,4 +342,90 @@ describe('Range', function () {
|
||||||
expect(from5to14.start).to.eql(8)
|
expect(from5to14.start).to.eql(8)
|
||||||
expect(from5to14.end).to.eql(18)
|
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
libraries/overleaf-editor-core/test/tracked_change.test.js
Normal file
55
libraries/overleaf-editor-core/test/tracked_change.test.js
Normal file
|
@ -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
libraries/overleaf-editor-core/test/tracked_change_list.test.js
Normal file
845
libraries/overleaf-editor-core/test/tracked_change_list.test.js
Normal file
|
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue