first commit
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// @ts-check
|
||||
const { expect } = require('chai')
|
||||
const { AddCommentOperation, DeleteCommentOperation } = require('..')
|
||||
const Range = require('../lib/range')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
|
||||
describe('AddCommentOperation', function () {
|
||||
it('constructs an AddCommentOperation fromJSON', function () {
|
||||
const op = AddCommentOperation.fromJSON({
|
||||
commentId: '123',
|
||||
resolved: true,
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
})
|
||||
expect(op).to.be.instanceOf(AddCommentOperation)
|
||||
expect(op.commentId).to.equal('123')
|
||||
expect(op.ranges[0]).to.be.instanceOf(Range)
|
||||
expect(op.resolved).to.be.true
|
||||
})
|
||||
|
||||
it('should convert to JSON', function () {
|
||||
const op = new AddCommentOperation('123', [new Range(0, 1)])
|
||||
expect(op.toJSON()).to.eql({
|
||||
commentId: '123',
|
||||
ranges: [
|
||||
{
|
||||
pos: 0,
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply operation', function () {
|
||||
const fileData = new StringFileData('abc')
|
||||
const op = new AddCommentOperation('123', [new Range(0, 1)])
|
||||
op.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('invert', function () {
|
||||
it('should delete added comment', function () {
|
||||
const initialFileData = new StringFileData('abc')
|
||||
const fileData = StringFileData.fromRaw(initialFileData.toRaw())
|
||||
const op = new AddCommentOperation('123', [new Range(0, 1)])
|
||||
op.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
},
|
||||
])
|
||||
const invertedOp = op.invert(initialFileData)
|
||||
invertedOp.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([])
|
||||
})
|
||||
|
||||
it('should restore previous comment ranges', function () {
|
||||
const initialComments = [
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
},
|
||||
]
|
||||
|
||||
const initialFileData = new StringFileData(
|
||||
'the quick brown fox jumps over the lazy dog',
|
||||
initialComments
|
||||
)
|
||||
const fileData = StringFileData.fromRaw(initialFileData.toRaw())
|
||||
const op = new AddCommentOperation('123', [new Range(12, 7)], true)
|
||||
op.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 12, length: 7 }],
|
||||
resolved: true,
|
||||
},
|
||||
])
|
||||
|
||||
const invertedOp = op.invert(initialFileData)
|
||||
invertedOp.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.deep.equal(initialComments)
|
||||
})
|
||||
|
||||
it('should restore previous comment resolution status', function () {
|
||||
const initialComments = [
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
},
|
||||
]
|
||||
|
||||
const initialFileData = new StringFileData(
|
||||
'the quick brown fox jumps over the lazy dog',
|
||||
initialComments
|
||||
)
|
||||
const fileData = StringFileData.fromRaw(initialFileData.toRaw())
|
||||
const op = new AddCommentOperation('123', [new Range(0, 1)], true)
|
||||
op.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{
|
||||
id: '123',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
resolved: true,
|
||||
},
|
||||
])
|
||||
|
||||
const invertedOp = op.invert(initialFileData)
|
||||
invertedOp.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.deep.equal(initialComments)
|
||||
})
|
||||
})
|
||||
|
||||
it('should compose with DeleteCommentOperation', function () {
|
||||
const addOp = new AddCommentOperation('123', [new Range(0, 1)])
|
||||
const deleteOp = new DeleteCommentOperation('123')
|
||||
expect(addOp.canBeComposedWith(deleteOp)).to.be.true
|
||||
|
||||
const composedOp = addOp.compose(deleteOp)
|
||||
expect(composedOp).to.be.instanceOf(DeleteCommentOperation)
|
||||
})
|
||||
})
|
62
libraries/overleaf-editor-core/test/change.test.js
Normal file
62
libraries/overleaf-editor-core/test/change.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const Operation = core.Operation
|
||||
|
||||
describe('Change', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from operations', function () {
|
||||
const blobHashes = new Set()
|
||||
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with content doesn't have a hash.
|
||||
change.pushOperation(Operation.addFile('a.txt', File.fromString('a')))
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with hash should give us a hash.
|
||||
change.pushOperation(
|
||||
Operation.addFile('b.txt', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
)
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(1)
|
||||
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('RestoreFileOrigin', function () {
|
||||
it('should convert to and from raw', function () {
|
||||
const origin = new core.RestoreFileOrigin(1, 'path', new Date())
|
||||
const raw = origin.toRaw()
|
||||
const newOrigin = core.Origin.fromRaw(raw)
|
||||
expect(newOrigin).to.eql(origin)
|
||||
})
|
||||
|
||||
it('change should have a correct origin class', function () {
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
origin: {
|
||||
kind: 'file-restore',
|
||||
version: 1,
|
||||
path: 'path',
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(change.getOrigin()).to.be.an.instanceof(core.RestoreFileOrigin)
|
||||
})
|
||||
})
|
||||
})
|
116
libraries/overleaf-editor-core/test/comment.test.js
Normal file
116
libraries/overleaf-editor-core/test/comment.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const Comment = require('../lib/comment')
|
||||
const Range = require('../lib/range')
|
||||
|
||||
describe('Comment', function () {
|
||||
it('should move ranges to the right of insert', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const resComment = comment.applyInsert(3, 5, false)
|
||||
expect(resComment.ranges).to.eql([new Range(10, 10)])
|
||||
})
|
||||
|
||||
describe('applyInsert', function () {
|
||||
it('should insert 1 char before the range', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
expect(comment.applyInsert(4, 1).ranges).to.eql([new Range(6, 10)])
|
||||
})
|
||||
|
||||
it('should insert 1 char at the edge, without expandCommand', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
expect(comment.applyInsert(5, 1).ranges).to.eql([new Range(6, 10)])
|
||||
})
|
||||
|
||||
it('should insert 1 char at the edge, with expandCommand', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
expect(comment.applyInsert(5, 1, true).ranges).to.eql([new Range(5, 11)])
|
||||
})
|
||||
|
||||
it('should expand the range after insert inside it', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
expect(comment.applyInsert(6, 1, true).ranges).to.eql([new Range(5, 11)])
|
||||
})
|
||||
})
|
||||
|
||||
it('should split the range if inside another and expandComment is false', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyInsert(6, 10, false)
|
||||
expect(commentRes.ranges).to.eql([new Range(5, 1), new Range(16, 9)])
|
||||
})
|
||||
|
||||
it('should insert the range if expandComment is false', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyInsert(14, 10, false)
|
||||
expect(commentRes.ranges).to.eql([new Range(5, 9), new Range(24, 1)])
|
||||
})
|
||||
|
||||
it('should move the range if insert is at range start and expandComment is false', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyInsert(5, 10, false)
|
||||
expect(commentRes.ranges).to.eql([new Range(15, 10)])
|
||||
})
|
||||
|
||||
it('should ignore the range if insert is at range end and expandComment is false', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyInsert(15, 10, false)
|
||||
expect(commentRes.ranges).to.eql([new Range(5, 10)])
|
||||
})
|
||||
|
||||
it('should expand the range after inserting on the edge of it if expandComment is true', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyInsert(15, 10, true)
|
||||
expect(commentRes.ranges).to.eql([new Range(5, 20)])
|
||||
})
|
||||
|
||||
it('should move comment ranges if delete is before it', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10)])
|
||||
const commentRes = comment.applyDelete(new Range(3, 5))
|
||||
expect(commentRes.ranges).to.eql([new Range(3, 7)])
|
||||
})
|
||||
|
||||
it('should merge ranges after delete', function () {
|
||||
const comment = new Comment('c1', [new Range(5, 10), new Range(20, 10)])
|
||||
const commentRes = comment.applyDelete(new Range(7, 18))
|
||||
expect(commentRes.ranges).to.eql([new Range(5, 7)])
|
||||
})
|
||||
|
||||
it('should merge overlapping ranges', function () {
|
||||
const comment = new Comment('c1', [
|
||||
new Range(5, 10),
|
||||
new Range(15, 20),
|
||||
new Range(50, 10),
|
||||
])
|
||||
expect(comment.ranges).to.eql([new Range(5, 30), new Range(50, 10)])
|
||||
})
|
||||
|
||||
it('should merge unsorted ranges', function () {
|
||||
const comment = new Comment('c1', [
|
||||
new Range(15, 20),
|
||||
new Range(50, 10),
|
||||
new Range(5, 10),
|
||||
])
|
||||
expect(comment.ranges).to.eql([new Range(5, 30), new Range(50, 10)])
|
||||
})
|
||||
|
||||
it('should throw error when ranges overlap', function () {
|
||||
expect(
|
||||
() =>
|
||||
new Comment('c1', [
|
||||
new Range(5, 10),
|
||||
new Range(10, 5),
|
||||
new Range(50, 10),
|
||||
])
|
||||
).to.throw()
|
||||
})
|
||||
|
||||
it('should join touching ranges', function () {
|
||||
const comment = new Comment('c1', [
|
||||
new Range(5, 10),
|
||||
new Range(15, 5),
|
||||
new Range(50, 10),
|
||||
])
|
||||
expect(comment.ranges).to.eql([new Range(5, 15), new Range(50, 10)])
|
||||
})
|
||||
})
|
430
libraries/overleaf-editor-core/test/comments_list.test.js
Normal file
430
libraries/overleaf-editor-core/test/comments_list.test.js
Normal file
@@ -0,0 +1,430 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const CommentList = require('../lib/file_data/comment_list')
|
||||
const Comment = require('../lib/comment')
|
||||
const Range = require('../lib/range')
|
||||
|
||||
describe('commentList', function () {
|
||||
it('checks if toRaw() returns a correct comment list', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)]),
|
||||
new Comment('comm2', [new Range(20, 5)]),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
])
|
||||
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should get a comment by id', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)]),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
new Comment('comm2', [new Range(20, 5)]),
|
||||
])
|
||||
|
||||
const comment = commentList.getComment('comm2')
|
||||
expect(comment?.toRaw()).to.eql({
|
||||
id: 'comm2',
|
||||
ranges: [
|
||||
{
|
||||
pos: 20,
|
||||
length: 5,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should add new comment to the list', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)]),
|
||||
new Comment('comm2', [new Range(20, 5)]),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
])
|
||||
|
||||
commentList.add(new Comment('comm4', [new Range(40, 10)]))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
{
|
||||
id: 'comm4',
|
||||
ranges: [{ pos: 40, length: 10 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should overwrite existing comment if new one is added', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)], false),
|
||||
new Comment('comm2', [new Range(20, 5)], true),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
])
|
||||
|
||||
commentList.add(new Comment('comm1', [new Range(5, 10)], true))
|
||||
commentList.add(new Comment('comm2', [new Range(40, 10)], true))
|
||||
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }], resolved: true },
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 40, length: 10 }],
|
||||
resolved: true,
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should delete a comment from the list', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)]),
|
||||
new Comment('comm2', [new Range(20, 5)]),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
])
|
||||
|
||||
commentList.delete('comm3')
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should not throw an error if comment id does not exist', function () {
|
||||
const commentList = new CommentList([
|
||||
new Comment('comm1', [new Range(5, 10)]),
|
||||
new Comment('comm2', [new Range(20, 5)]),
|
||||
new Comment('comm3', [new Range(30, 15)]),
|
||||
])
|
||||
|
||||
commentList.delete('comm5')
|
||||
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should be iterable', function () {
|
||||
const comment = new Comment('comm1', [new Range(5, 10)])
|
||||
const commentList = new CommentList([comment])
|
||||
expect(Array.from(commentList)).to.deep.equal([comment])
|
||||
})
|
||||
|
||||
describe('inserting a comment between ranges', function () {
|
||||
it('should expand comment on the left', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 15, length: 10 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(15, 5), { commentIds: ['comm1'] })
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 15 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 10 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should expand comment on the right', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 15, length: 10 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(15, 5), { commentIds: ['comm2'] })
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 15, length: 15 }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete a text overlapping two comments', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }], // 5-14
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 15, length: 10 }], // 15-24
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyDelete(new Range(10, 10)) // 10-19
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 5 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 10, length: 5 }] },
|
||||
])
|
||||
})
|
||||
|
||||
describe('move ranges after insert/delete operations', function () {
|
||||
it('expands comments inside inserted text', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(7, 5), { commentIds: ['comm1'] })
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 15 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 25, length: 5 }] },
|
||||
{ id: 'comm3', ranges: [{ pos: 35, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should insert an overlapping comment without overlapped comment id', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(7, 5), { commentIds: ['comm2'] })
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [
|
||||
{ pos: 5, length: 2 },
|
||||
{ pos: 12, length: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [
|
||||
{ pos: 7, length: 5 },
|
||||
{ pos: 25, length: 5 },
|
||||
],
|
||||
},
|
||||
{ id: 'comm3', ranges: [{ pos: 35, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should insert an overlapping comment with overlapped comment id', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 15 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(7, 5), {
|
||||
commentIds: ['comm1', 'comm2'],
|
||||
})
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 20 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [
|
||||
{ pos: 7, length: 5 },
|
||||
{ pos: 25, length: 5 },
|
||||
],
|
||||
},
|
||||
{ id: 'comm3', ranges: [{ pos: 35, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('moves comments after inserted text', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(16, 5))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 25, length: 5 }] },
|
||||
{ id: 'comm3', ranges: [{ pos: 35, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not affect comments outside of inserted text', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyInsert(new Range(50, 5))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
{ id: 'comm3', ranges: [{ pos: 30, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should move comments if delete happened before it', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyDelete(new Range(0, 4))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 1, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 16, length: 5 }] },
|
||||
{ id: 'comm3', ranges: [{ pos: 26, length: 15 }] },
|
||||
])
|
||||
})
|
||||
|
||||
describe('should remove part of a comment on delete overlapping', function () {
|
||||
it('should delete intersection from the left', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyDelete(new Range(0, 6))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 0, length: 9 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should delete intersection from the right', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
])
|
||||
commentList.applyDelete(new Range(7, 10))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 2 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('should delete intersection in the middle', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
])
|
||||
commentList.applyDelete(new Range(6, 2))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 8 }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave comment without ranges', function () {
|
||||
const commentList = CommentList.fromRaw([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [{ pos: 20, length: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 30, length: 15 }],
|
||||
},
|
||||
])
|
||||
|
||||
commentList.applyDelete(new Range(19, 10))
|
||||
expect(commentList.toRaw()).to.eql([
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [{ pos: 5, length: 10 }],
|
||||
},
|
||||
{ id: 'comm2', ranges: [] },
|
||||
{
|
||||
id: 'comm3',
|
||||
ranges: [{ pos: 20, length: 15 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,46 @@
|
||||
// @ts-check
|
||||
const { expect } = require('chai')
|
||||
const { AddCommentOperation, DeleteCommentOperation } = require('..')
|
||||
const Comment = require('../lib/comment')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const Range = require('../lib/range')
|
||||
|
||||
describe('DeleteCommentOperation', function () {
|
||||
it('constructs an DeleteCommentOperation fromJSON', function () {
|
||||
const op = DeleteCommentOperation.fromJSON({
|
||||
deleteComment: '123',
|
||||
})
|
||||
expect(op).to.be.instanceOf(DeleteCommentOperation)
|
||||
})
|
||||
|
||||
it('should convert to JSON', function () {
|
||||
const op = new DeleteCommentOperation('123')
|
||||
expect(op.toJSON()).to.eql({
|
||||
deleteComment: '123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply operation', function () {
|
||||
const fileData = new StringFileData('abc')
|
||||
const op = new DeleteCommentOperation('123')
|
||||
fileData.comments.add(new Comment('123', [new Range(0, 1)]))
|
||||
op.apply(fileData)
|
||||
expect(fileData.getComments().toRaw()).to.eql([])
|
||||
})
|
||||
|
||||
it('should invert operation', function () {
|
||||
const fileData = new StringFileData('abc')
|
||||
const op = new DeleteCommentOperation('123')
|
||||
fileData.comments.add(new Comment('123', [new Range(0, 1)]))
|
||||
const invertedOp = /** @type {AddCommentOperation} */ (op.invert(fileData))
|
||||
expect(invertedOp).to.be.instanceOf(AddCommentOperation)
|
||||
expect(invertedOp.commentId).to.equal('123')
|
||||
expect(invertedOp.ranges).to.eql([new Range(0, 1)])
|
||||
})
|
||||
|
||||
it('should not throw if comment not found', function () {
|
||||
const fileData = new StringFileData('abc')
|
||||
const op = new DeleteCommentOperation('123')
|
||||
expect(() => op.invert(fileData)).to.not.throw()
|
||||
})
|
||||
})
|
@@ -0,0 +1,81 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const EditOperationBuilder = require('../lib/operation/edit_operation_builder')
|
||||
const File = ot.File
|
||||
const Operation = ot.Operation
|
||||
|
||||
describe('EditFileOperation', function () {
|
||||
function edit(pathname, textOperationJsonObject) {
|
||||
return Operation.editFile(
|
||||
pathname,
|
||||
EditOperationBuilder.fromJSON({ textOperation: textOperationJsonObject })
|
||||
)
|
||||
}
|
||||
|
||||
describe('canBeComposedWith', function () {
|
||||
it('on the same file', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('on different files', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('bar.tex', ['y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with a different type of opperation', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = Operation.addFile(
|
||||
'bar.tex',
|
||||
File.fromString('')
|
||||
)
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with incompatible lengths', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [2, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('canBeComposedWithForUndo', function () {
|
||||
it('can', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('cannot', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', ['y', 1, 'z'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('compose', function () {
|
||||
it('composes text operations', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
const composedFileOperation =
|
||||
editFileOperation1.compose(editFileOperation2)
|
||||
const expectedComposedFileOperation = edit('foo.tex', ['xy'])
|
||||
expect(composedFileOperation).to.deep.equal(expectedComposedFileOperation)
|
||||
|
||||
// check that the original operation wasn't modified
|
||||
expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x']))
|
||||
})
|
||||
})
|
||||
})
|
315
libraries/overleaf-editor-core/test/edit_operation.test.js
Normal file
315
libraries/overleaf-editor-core/test/edit_operation.test.js
Normal file
@@ -0,0 +1,315 @@
|
||||
const { expect } = require('chai')
|
||||
const EditOperationBuilder = require('../lib/operation/edit_operation_builder')
|
||||
const TextOperation = require('../lib/operation/text_operation')
|
||||
const EditOperationTransformer = require('../lib/operation/edit_operation_transformer')
|
||||
const EditOperation = require('../lib/operation/edit_operation')
|
||||
const randomTextOperation = require('./support/random_text_operation')
|
||||
const random = require('./support/random')
|
||||
const AddCommentOperation = require('../lib/operation/add_comment_operation')
|
||||
const DeleteCommentOperation = require('../lib/operation/delete_comment_operation')
|
||||
const SetCommentStateOperation = require('../lib/operation/set_comment_state_operation')
|
||||
const Range = require('../lib/range')
|
||||
const EditNoOperation = require('../lib/operation/edit_no_operation')
|
||||
|
||||
describe('EditOperation', function () {
|
||||
it('Cannot be instantiated', function () {
|
||||
expect(() => new EditOperation()).to.throw(
|
||||
'Cannot instantiate abstract class'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditOperationTransformer', function () {
|
||||
it('Transforms two TextOperations', function () {
|
||||
const a = new TextOperation().insert('foo')
|
||||
const b = new TextOperation().insert('bar')
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(bPrime).to.be.an.instanceof(TextOperation)
|
||||
})
|
||||
|
||||
it('Transforms TextOperation and EditNoOperation', function () {
|
||||
const a = new TextOperation().insert('foo')
|
||||
const b = new EditNoOperation()
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(bPrime).to.be.an.instanceof(EditNoOperation)
|
||||
})
|
||||
|
||||
it('Transforms two AddCommentOperations with same commentId', function () {
|
||||
const a = new AddCommentOperation('comm1', [new Range(0, 1)])
|
||||
const b = new AddCommentOperation('comm1', [new Range(2, 3)])
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(EditNoOperation)
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
})
|
||||
|
||||
it('Transforms two AddCommentOperations with different commentId', function () {
|
||||
const a = new AddCommentOperation('comm1', [new Range(0, 1)])
|
||||
const b = new AddCommentOperation('comm2', [new Range(2, 3)])
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms two DeleteCommentOperations with same commentId', function () {
|
||||
const a = new DeleteCommentOperation('comm1')
|
||||
const b = new DeleteCommentOperation('comm1')
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(EditNoOperation)
|
||||
expect(bPrime).to.be.an.instanceof(EditNoOperation)
|
||||
})
|
||||
|
||||
it('Transforms two DeleteCommentOperations with different commentId', function () {
|
||||
const a = new DeleteCommentOperation('comm1')
|
||||
const b = new DeleteCommentOperation('comm2')
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms AddCommentOperation and DeleteCommentOperation with same commentId', function () {
|
||||
const a = new AddCommentOperation('comm1', [new Range(0, 1)])
|
||||
const b = new DeleteCommentOperation('comm1')
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(EditNoOperation)
|
||||
expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms DeleteCommentOperation and AddCommentOperation with same commentId', function () {
|
||||
const a = new DeleteCommentOperation('comm1')
|
||||
const b = new AddCommentOperation('comm1', [new Range(0, 1)])
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(EditNoOperation)
|
||||
})
|
||||
|
||||
it('Transforms AddCommentOperation and TextOperation', function () {
|
||||
// abc hello[ world] xyz - insert(9, " world")
|
||||
// abc hello |xyz| - addComment(10, 3, "comment_id")
|
||||
|
||||
const a = new TextOperation().retain(9).insert(' world')
|
||||
const b = new AddCommentOperation('comm1', [new Range(10, 3)])
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 16, length: 3 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('Transforms TextOperation and AddCommentOperation', function () {
|
||||
// abc hello |xyz| - addComment(10, 3, "comment_id")
|
||||
// abc hello[ world] xyz - insert(9, " world")
|
||||
|
||||
const a = new AddCommentOperation('comm1', [new Range(10, 3)])
|
||||
const b = new TextOperation().retain(9).insert(' world')
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(bPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
expect(aPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(aPrime.toJSON()).to.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 16, length: 3 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('Transforms AddCommentOperation and TextOperation that makes a detached comment', function () {
|
||||
// [abc hello xyz] - delete(0, 13)
|
||||
// abc |hello| xyz - addComment(5, 5, "comment_id")
|
||||
|
||||
const a = new TextOperation().remove(13)
|
||||
const b = new AddCommentOperation('comm1', [new Range(5, 5)])
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('Transforms AddCommentOperation and deletion TextOperation', function () {
|
||||
// abc hell{o xy}z - retain(8).delete(4)
|
||||
// abc hello |xyz| - addComment(10, 3, "comment_id")
|
||||
// abc hell|z|
|
||||
|
||||
const a = new TextOperation().retain(8).remove(4)
|
||||
const b = new AddCommentOperation('comm1', [new Range(10, 3)])
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 8, length: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('Transforms AddCommentOperation and complex TextOperation', function () {
|
||||
// [foo ]abc hell{o xy}z - insert(0, "foo ").retain(8).delete(4)
|
||||
// abc hello |xyz| - addComment(10, 3, "comment_id")
|
||||
// foo abc hell|z|
|
||||
|
||||
const a = new TextOperation().insert('foo ').retain(8).remove(4)
|
||||
const b = new AddCommentOperation('comm1', [new Range(10, 3)])
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 12, length: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('Transforms DeleteCommentOperation and TextOperation', function () {
|
||||
const a = new TextOperation().retain(9).insert(' world')
|
||||
const b = new DeleteCommentOperation('comm1')
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms SetCommentStateOperation and TextOperation', function () {
|
||||
const a = new TextOperation().retain(9).insert(' world')
|
||||
const b = new SetCommentStateOperation('comm1', true)
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(TextOperation)
|
||||
expect(aPrime.toJSON()).to.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(SetCommentStateOperation)
|
||||
expect(bPrime.toJSON()).to.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms SetCommentStateOperation and AddCommentOperation', function () {
|
||||
const a = new AddCommentOperation('comm1', [new Range(0, 1)])
|
||||
const b = new SetCommentStateOperation('comm1', true)
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(aPrime.toJSON()).to.deep.eql({
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
resolved: true,
|
||||
})
|
||||
expect(bPrime).to.be.an.instanceof(SetCommentStateOperation)
|
||||
expect(bPrime.toJSON()).to.deep.eql(b.toJSON())
|
||||
})
|
||||
|
||||
it('Transforms SetCommentStateOperation and DeleteCommentOperation', function () {
|
||||
const a = new DeleteCommentOperation('comm1')
|
||||
const b = new SetCommentStateOperation('comm1', true)
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(aPrime.toJSON()).to.deep.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(EditNoOperation)
|
||||
})
|
||||
|
||||
it('Transforms SetCommentStateOperation and SetCommentStateOperation', function () {
|
||||
const a = new SetCommentStateOperation('comm1', false)
|
||||
const b = new SetCommentStateOperation('comm1', true)
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime.toJSON()).to.deep.eql({
|
||||
commentId: 'comm1',
|
||||
resolved: false,
|
||||
})
|
||||
expect(bPrime).to.be.an.instanceof(EditNoOperation)
|
||||
})
|
||||
|
||||
it('Transforms two SetCommentStateOperation with different commentId', function () {
|
||||
const a = new SetCommentStateOperation('comm1', false)
|
||||
const b = new SetCommentStateOperation('comm2', true)
|
||||
|
||||
const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
|
||||
expect(aPrime).to.be.an.instanceof(SetCommentStateOperation)
|
||||
expect(aPrime.toJSON()).to.deep.eql(a.toJSON())
|
||||
expect(bPrime).to.be.an.instanceof(SetCommentStateOperation)
|
||||
expect(bPrime.toJSON()).to.deep.eql(b.toJSON())
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditOperationBuilder', function () {
|
||||
it('Constructs TextOperation from JSON', function () {
|
||||
const raw = {
|
||||
textOperation: [1, 'foo', 3],
|
||||
}
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(TextOperation)
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Constructs AddCommentOperation from JSON', function () {
|
||||
const raw = {
|
||||
commentId: 'comm1',
|
||||
ranges: [{ pos: 0, length: 1 }],
|
||||
}
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(AddCommentOperation)
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Constructs DeleteCommentOperation from JSON', function () {
|
||||
const raw = {
|
||||
deleteComment: 'comm1',
|
||||
}
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(DeleteCommentOperation)
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Constructs SetCommentStateOperation from JSON', function () {
|
||||
const raw = {
|
||||
commentId: 'comm1',
|
||||
resolved: true,
|
||||
}
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(SetCommentStateOperation)
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Constructs EditNoOperation from JSON', function () {
|
||||
const raw = { noOp: true }
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(EditNoOperation)
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Throws error for unsupported operation', function () {
|
||||
const raw = {
|
||||
unsupportedOperation: {
|
||||
op: 'foo',
|
||||
},
|
||||
}
|
||||
expect(() => EditOperationBuilder.fromJSON(raw)).to.throw(
|
||||
'Unsupported operation in EditOperationBuilder.fromJSON'
|
||||
)
|
||||
})
|
||||
|
||||
it('Constructs TextOperation from JSON (randomised)', function () {
|
||||
const str = random.string(50)
|
||||
const randomOperation = randomTextOperation(str)
|
||||
const op = EditOperationBuilder.fromJSON(randomOperation.toJSON())
|
||||
expect(op).to.be.an.instanceof(TextOperation)
|
||||
expect(op.equals(randomOperation)).to.be.true
|
||||
})
|
||||
})
|
96
libraries/overleaf-editor-core/test/file.test.js
Normal file
96
libraries/overleaf-editor-core/test/file.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const FakeBlobStore = require('./support/fake_blob_store')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
|
||||
describe('File', function () {
|
||||
it('can have attached metadata', function () {
|
||||
// no metadata
|
||||
let file = File.fromString('foo')
|
||||
expect(file.getMetadata()).to.eql({})
|
||||
|
||||
// metadata passed in at construction time
|
||||
file = File.fromString('foo', { main: true })
|
||||
expect(file.getMetadata()).to.eql({ main: true })
|
||||
|
||||
// metadata set at runtime
|
||||
file.setMetadata({ main: false })
|
||||
expect(file.getMetadata()).to.eql({ main: false })
|
||||
})
|
||||
|
||||
describe('toRaw', function () {
|
||||
it('returns non-empty metadata', function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, undefined, metadata)
|
||||
expect(file.toRaw()).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata,
|
||||
})
|
||||
|
||||
delete file.getMetadata().main
|
||||
expect(file.toRaw()).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, undefined, metadata)
|
||||
const raw = file.toRaw()
|
||||
const fileMetadata = file.getMetadata()
|
||||
const rawMetadata = raw.metadata
|
||||
expect(rawMetadata).not.to.equal(fileMetadata)
|
||||
expect(rawMetadata).to.deep.equal(fileMetadata)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store', function () {
|
||||
it('does not return empty metadata', async function () {
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
const raw = await file.store(fakeBlobStore)
|
||||
expect(raw).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
|
||||
it('returns non-empty metadata', async function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, undefined, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
const raw = await file.store(fakeBlobStore)
|
||||
expect(raw).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', async function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, undefined, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
const raw = await file.store(fakeBlobStore)
|
||||
raw.metadata.externalFile.id = 456
|
||||
expect(file.getMetadata().externalFile.id).to.equal(123)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with string data', function () {
|
||||
it('can be created from a string', function () {
|
||||
const file = File.fromString('foo')
|
||||
expect(file.getContent()).to.equal('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with hollow string data', function () {
|
||||
it('can be cloned', function () {
|
||||
const file = File.createHollow(null, 0)
|
||||
expect(file.getStringLength()).to.equal(0)
|
||||
const clone = file.clone()
|
||||
expect(clone.getStringLength()).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('getComments() returns an empty comment list', function () {
|
||||
const file = File.fromString('foo')
|
||||
expect(file.getComments().toRaw()).to.eql([])
|
||||
})
|
||||
})
|
195
libraries/overleaf-editor-core/test/file_map.test.js
Normal file
195
libraries/overleaf-editor-core/test/file_map.test.js
Normal file
@@ -0,0 +1,195 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const FileMap = ot.FileMap
|
||||
|
||||
describe('FileMap', function () {
|
||||
function makeTestFile(pathname) {
|
||||
return File.fromString(pathname)
|
||||
}
|
||||
|
||||
function makeTestFiles(pathnames) {
|
||||
return _.zipObject(pathnames, _.map(pathnames, makeTestFile))
|
||||
}
|
||||
|
||||
function makeFileMap(pathnames) {
|
||||
return new FileMap(makeTestFiles(pathnames))
|
||||
}
|
||||
|
||||
it('allows construction with a single file', function () {
|
||||
makeFileMap(['a'])
|
||||
})
|
||||
|
||||
it('allows folders to differ by case', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a/b', 'A/c'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'A/b/d'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'a/B/d'])
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('does not allow conflicting paths on construct', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a', 'a/b'])
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('detects conflicting paths with characters that sort before /', function () {
|
||||
const fileMap = makeFileMap(['a', 'a!'])
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
})
|
||||
|
||||
it('detects conflicting paths', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/c/d')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/d')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('d/b/c')).to.be.falsy
|
||||
})
|
||||
|
||||
it('allows paths that differ by case', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/C')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/B')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/B')).to.be.falsy
|
||||
})
|
||||
|
||||
it('does not add a file with a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
const file = makeTestFile('a/b/c')
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('a/b/c', file)
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not move a file to a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('errors when trying to move a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.moveFile('b', 'a')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('moves a file over an empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('a').getContent()).to.equal('a/b')
|
||||
})
|
||||
|
||||
it('does not move a file over a non-empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on add', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.addFile('A', makeTestFile('A'))
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('A')
|
||||
})
|
||||
|
||||
it('changes case on move', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.moveFile('a', 'A')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on move', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.moveFile('a', 'B')
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
expect(fileMap.files.B).to.exist
|
||||
expect(fileMap.getFile('B').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not find pathname that differs by case', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A')).not.to.exist
|
||||
expect(fileMap.getFile('b')).not.to.exist
|
||||
})
|
||||
|
||||
it('does not allow non-safe pathnames', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['c*'])
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
const fileMap = makeFileMap([])
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('c*', makeTestFile('c:'))
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
fileMap.addFile('a', makeTestFile('a'))
|
||||
expect(() => {
|
||||
fileMap.moveFile('a', 'c*')
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('hasOwnProperty', makeTestFile('hasOwnProperty'))
|
||||
fileMap.addFile('anotherFile', makeTestFile('anotherFile'))
|
||||
}).to.throw()
|
||||
})
|
||||
|
||||
it('removes a file', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.removeFile('a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
})
|
||||
|
||||
it('errors when trying to remove a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.removeFile('b')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('has mapAsync', async function () {
|
||||
const concurrency = 1
|
||||
for (const test of [
|
||||
[[], {}],
|
||||
[['a'], { a: 'a-a' }], // the test is to map to "content-pathname"
|
||||
[['a', 'b'], { a: 'a-a', b: 'b-b' }],
|
||||
]) {
|
||||
const input = test[0]
|
||||
const expectedOutput = test[1]
|
||||
const fileMap = makeFileMap(input)
|
||||
const result = await fileMap.mapAsync((file, pathname) => {
|
||||
return file.getContent() + '-' + pathname
|
||||
}, concurrency)
|
||||
expect(result).to.deep.equal(expectedOutput)
|
||||
}
|
||||
})
|
||||
})
|
124
libraries/overleaf-editor-core/test/hash_file_data.test.js
Normal file
124
libraries/overleaf-editor-core/test/hash_file_data.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const HashFileData = require('../lib/file_data/hash_file_data')
|
||||
const { expect } = require('chai')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const sinon = require('sinon')
|
||||
const Blob = require('../lib/blob')
|
||||
|
||||
describe('HashFileData', function () {
|
||||
beforeEach(function () {
|
||||
this.fileHash = 'a5675307b61ec2517330622a6e649b4ca1ee5612'
|
||||
this.rangesHash = '380de212d09bf8498065833dbf242aaf11184316'
|
||||
this.blobStore = {
|
||||
getString: sinon.stub(),
|
||||
getObject: sinon.stub(),
|
||||
getBlob: sinon.stub(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('constructor', function () {
|
||||
it('should create a new instance of HashFileData from content hash and ranges hash', function () {
|
||||
const fileData = new HashFileData(this.fileHash, this.rangesHash)
|
||||
|
||||
expect(fileData).to.be.instanceOf(HashFileData)
|
||||
expect(fileData.getHash()).to.equal(this.fileHash)
|
||||
expect(fileData.getRangesHash()).to.equal(this.rangesHash)
|
||||
})
|
||||
|
||||
it('should create a new instance of HashFileData with no ranges hash', function () {
|
||||
const fileData = new HashFileData(this.fileHash)
|
||||
expect(fileData).to.be.instanceOf(HashFileData)
|
||||
expect(fileData.getHash()).to.equal(this.fileHash)
|
||||
expect(fileData.getRangesHash()).to.be.undefined
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromRaw', function () {
|
||||
it('should create a new instance of HashFileData from raw data', function () {
|
||||
const raw = { hash: this.fileHash, rangesHash: this.rangesHash }
|
||||
const fileData = HashFileData.fromRaw(raw)
|
||||
|
||||
expect(fileData).to.be.instanceOf(HashFileData)
|
||||
expect(fileData.getHash()).to.equal(raw.hash)
|
||||
expect(fileData.getRangesHash()).to.equal(raw.rangesHash)
|
||||
})
|
||||
|
||||
it('should create a new instance of HashFileData from raw data without ranges hash', function () {
|
||||
const raw = { hash: this.fileHash }
|
||||
const fileData = HashFileData.fromRaw(raw)
|
||||
|
||||
expect(fileData).to.be.instanceOf(HashFileData)
|
||||
expect(fileData.getHash()).to.equal(raw.hash)
|
||||
expect(fileData.getRangesHash()).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRaw', function () {
|
||||
it('should include ranges hash when present', function () {
|
||||
const fileData = new HashFileData(this.fileHash, this.rangesHash)
|
||||
const raw = fileData.toRaw()
|
||||
expect(raw).to.deep.equal({
|
||||
hash: this.fileHash,
|
||||
rangesHash: this.rangesHash,
|
||||
})
|
||||
})
|
||||
|
||||
it('should omit ranges hash when not present', function () {
|
||||
const fileData = new HashFileData(this.fileHash)
|
||||
const raw = fileData.toRaw()
|
||||
expect(raw).to.deep.equal({
|
||||
hash: this.fileHash,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toEager', function () {
|
||||
it('should convert HashFileData to StringFileData including ranges', async function () {
|
||||
const trackedChanges = [
|
||||
{
|
||||
range: { pos: 5, length: 10 },
|
||||
tracking: {
|
||||
userId: 'foo',
|
||||
type: 'insert',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
]
|
||||
const comments = [
|
||||
{
|
||||
id: 'comment-1',
|
||||
ranges: [{ pos: 1, length: 4 }],
|
||||
},
|
||||
]
|
||||
const fileData = new HashFileData(this.fileHash, this.rangesHash)
|
||||
this.blobStore.getString.withArgs(this.fileHash).resolves('content')
|
||||
this.blobStore.getObject.withArgs(this.rangesHash).resolves({
|
||||
trackedChanges,
|
||||
comments,
|
||||
})
|
||||
this.blobStore.getBlob
|
||||
.withArgs(this.rangesHash)
|
||||
.resolves(new Blob(this.rangesHash, 20, 20))
|
||||
this.blobStore.getBlob
|
||||
.withArgs(this.fileHash)
|
||||
.resolves(new Blob(this.fileHash, 20, 20))
|
||||
const eagerFileData = await fileData.toEager(this.blobStore)
|
||||
expect(eagerFileData).to.be.instanceOf(StringFileData)
|
||||
expect(eagerFileData.getContent()).to.equal('content')
|
||||
expect(eagerFileData.trackedChanges.toRaw()).to.deep.equal(trackedChanges)
|
||||
expect(eagerFileData.getComments().toRaw()).to.deep.equal(comments)
|
||||
})
|
||||
|
||||
it('should convert HashFileData to StringFileData without ranges', async function () {
|
||||
const fileData = new HashFileData(this.fileHash, undefined)
|
||||
this.blobStore.getString.withArgs(this.fileHash).resolves('content')
|
||||
this.blobStore.getBlob
|
||||
.withArgs(this.fileHash)
|
||||
.resolves(new Blob(this.fileHash, 20, 20))
|
||||
const eagerFileData = await fileData.toEager(this.blobStore)
|
||||
expect(eagerFileData).to.be.instanceOf(StringFileData)
|
||||
expect(eagerFileData.getContent()).to.equal('content')
|
||||
expect(eagerFileData.trackedChanges.toRaw()).to.deep.equal([])
|
||||
expect(eagerFileData.getComments().toRaw()).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
})
|
42
libraries/overleaf-editor-core/test/history.test.js
Normal file
42
libraries/overleaf-editor-core/test/history.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const History = core.History
|
||||
const Operation = core.Operation
|
||||
const Snapshot = core.Snapshot
|
||||
|
||||
describe('History', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from snapshot and changes', function () {
|
||||
const history = new History(new Snapshot(), [])
|
||||
|
||||
const blobHashes = new Set()
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash to the snapshot.
|
||||
history.getSnapshot().addFile('foo', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
|
||||
// Add a file with a hash to the changes.
|
||||
const testHash = 'a'.repeat(40)
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
change.pushOperation(Operation.addFile('bar', File.fromHash(testHash)))
|
||||
|
||||
history.pushChanges([change])
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([
|
||||
File.EMPTY_FILE_HASH,
|
||||
testHash,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const HollowStringFileData = require('../lib/file_data/hollow_string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('HollowStringFileData', function () {
|
||||
it('validates string length when edited', function () {
|
||||
const maxLength = TextOperation.MAX_STRING_LENGTH
|
||||
const fileData = new HollowStringFileData(maxLength)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(maxLength).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
fileData.edit(new TextOperation().retain(maxLength - 1).remove(1))
|
||||
expect(fileData.getStringLength()).to.equal(maxLength - 1)
|
||||
})
|
||||
})
|
17
libraries/overleaf-editor-core/test/label.test.js
Normal file
17
libraries/overleaf-editor-core/test/label.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const Label = ot.Label
|
||||
|
||||
describe('Label', function () {
|
||||
it('can be created by an anonymous author', function () {
|
||||
const label = Label.fromRaw({
|
||||
text: 'test',
|
||||
authorId: null,
|
||||
timestamp: '2016-01-01T00:00:00Z',
|
||||
version: 123,
|
||||
})
|
||||
expect(label.getAuthorId()).to.be.null
|
||||
})
|
||||
})
|
@@ -0,0 +1,196 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const TextOperation = ot.TextOperation
|
||||
const LazyStringFileData = require('../lib/file_data/lazy_string_file_data')
|
||||
const EagerStringFileData = require('../lib/file_data/string_file_data')
|
||||
|
||||
describe('LazyStringFileData', function () {
|
||||
beforeEach(function () {
|
||||
this.rangesHash = '380de212d09bf8498065833dbf242aaf11184316'
|
||||
this.fileHash = 'a5675307b61ec2517330622a6e649b4ca1ee5612'
|
||||
this.blobStore = {
|
||||
getString: sinon.stub(),
|
||||
putString: sinon.stub().resolves(new ot.Blob(this.fileHash, 19, 19)),
|
||||
getObject: sinon.stub(),
|
||||
putObject: sinon.stub().resolves(new ot.Blob(this.rangesHash, 204, 204)),
|
||||
}
|
||||
this.blobStore.getString.withArgs(File.EMPTY_FILE_HASH).resolves('')
|
||||
this.blobStore.getString
|
||||
.withArgs(this.fileHash)
|
||||
.resolves('the quick brown fox')
|
||||
this.blobStore.getObject.withArgs(this.rangesHash).resolves({
|
||||
comments: [{ id: 'foo', ranges: [{ pos: 0, length: 3 }] }],
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 5 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses raw text operations for toRaw and fromRaw', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, undefined, 0)
|
||||
let roundTripFileData
|
||||
|
||||
expect(fileData.toRaw()).to.deep.equal({
|
||||
hash: testHash,
|
||||
stringLength: 0,
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).to.equal(testHash)
|
||||
expect(roundTripFileData.getStringLength()).to.equal(0)
|
||||
expect(roundTripFileData.getOperations()).to.have.length(0)
|
||||
|
||||
fileData.edit(new TextOperation().insert('a'))
|
||||
expect(fileData.toRaw()).to.deep.equal({
|
||||
hash: testHash,
|
||||
stringLength: 1,
|
||||
operations: [{ textOperation: ['a'] }],
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).not.to.exist // file has changed
|
||||
expect(roundTripFileData.getStringLength()).to.equal(1)
|
||||
expect(roundTripFileData.getOperations()).to.have.length(1)
|
||||
expect(roundTripFileData.getOperations()[0]).to.be.instanceOf(TextOperation)
|
||||
expect(
|
||||
/** @type {InstanceType<TextOperation>} */ (
|
||||
roundTripFileData.getOperations()[0]
|
||||
).ops
|
||||
).to.have.length(1)
|
||||
|
||||
fileData.edit(new TextOperation().retain(1).insert('b'))
|
||||
expect(fileData.toRaw()).to.deep.equal({
|
||||
hash: testHash,
|
||||
stringLength: 2,
|
||||
operations: [{ textOperation: ['a'] }, { textOperation: [1, 'b'] }],
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).not.to.exist // file has changed
|
||||
expect(roundTripFileData.getStringLength()).to.equal(2)
|
||||
expect(roundTripFileData.getOperations()).to.have.length(2)
|
||||
expect(
|
||||
/** @type {InstanceType<TextOperation>} */ (
|
||||
roundTripFileData.getOperations()[0]
|
||||
).ops
|
||||
).to.have.length(1)
|
||||
expect(
|
||||
/** @type {InstanceType<TextOperation>} */ (
|
||||
roundTripFileData.getOperations()[1]
|
||||
).ops
|
||||
).to.have.length(2)
|
||||
})
|
||||
|
||||
it('should include rangesHash in toRaw and fromRaw when available', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const rangesHash = this.rangesHash
|
||||
const fileData = new LazyStringFileData(testHash, rangesHash, 19)
|
||||
|
||||
expect(fileData.toRaw()).to.deep.equal({
|
||||
hash: testHash,
|
||||
rangesHash,
|
||||
stringLength: 19,
|
||||
})
|
||||
|
||||
const roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).to.equal(testHash)
|
||||
expect(roundTripFileData.getRangesHash()).to.equal(rangesHash)
|
||||
expect(roundTripFileData.getStringLength()).to.equal(19)
|
||||
expect(roundTripFileData.getOperations()).to.have.length(0)
|
||||
})
|
||||
|
||||
it('should fetch content from blob store when loading eager string', async function () {
|
||||
const testHash = this.fileHash
|
||||
const rangesHash = this.rangesHash
|
||||
const fileData = new LazyStringFileData(testHash, rangesHash, 19)
|
||||
const eagerString = await fileData.toEager(this.blobStore)
|
||||
expect(eagerString).to.be.instanceOf(EagerStringFileData)
|
||||
expect(eagerString.getContent()).to.equal('the quick brown fox')
|
||||
expect(eagerString.getComments().toRaw()).to.deep.equal([
|
||||
{ id: 'foo', ranges: [{ pos: 0, length: 3 }] },
|
||||
])
|
||||
expect(eagerString.trackedChanges.toRaw()).to.deep.equal([
|
||||
{
|
||||
range: { pos: 4, length: 5 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(this.blobStore.getObject.calledWith(rangesHash)).to.be.true
|
||||
expect(this.blobStore.getString.calledWith(testHash)).to.be.true
|
||||
})
|
||||
|
||||
it('should not fetch ranges from blob store if not present', async function () {
|
||||
const testHash = this.fileHash
|
||||
const fileData = new LazyStringFileData(testHash, undefined, 19)
|
||||
const eagerString = await fileData.toEager(this.blobStore)
|
||||
expect(eagerString).to.be.instanceOf(EagerStringFileData)
|
||||
expect(eagerString.getContent()).to.equal('the quick brown fox')
|
||||
expect(eagerString.getComments().toRaw()).to.be.empty
|
||||
expect(eagerString.trackedChanges.length).to.equal(0)
|
||||
expect(this.blobStore.getObject.called).to.be.false
|
||||
expect(this.blobStore.getString.calledWith(testHash)).to.be.true
|
||||
})
|
||||
|
||||
it('validates operations when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, undefined, 0)
|
||||
expect(fileData.getHash()).equal(testHash)
|
||||
expect(fileData.getByteLength()).to.equal(0) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(0)
|
||||
expect(fileData.getOperations()).to.have.length(0)
|
||||
|
||||
fileData.edit(new TextOperation().insert('a'))
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(1) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(1)
|
||||
expect(fileData.getOperations()).to.have.length(1)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(10))
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(1) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(1)
|
||||
expect(fileData.getOperations()).to.have.length(1)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, undefined, 0)
|
||||
expect(fileData.getHash()).equal(testHash)
|
||||
expect(fileData.getByteLength()).to.equal(0) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(0)
|
||||
expect(fileData.getOperations()).to.have.length(0)
|
||||
|
||||
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
|
||||
fileData.edit(new TextOperation().insert(longString))
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
expect(fileData.getOperations()).to.have.length(1)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
expect(fileData.getOperations()).to.have.length(1)
|
||||
})
|
||||
})
|
@@ -0,0 +1,64 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const MoveFileOperation = ot.MoveFileOperation
|
||||
const Snapshot = ot.Snapshot
|
||||
const Operation = ot.Operation
|
||||
const V2DocVersions = ot.V2DocVersions
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('MoveFileOperation', function () {
|
||||
function makeEmptySnapshot() {
|
||||
return new Snapshot()
|
||||
}
|
||||
|
||||
function makeOneFileSnapshot() {
|
||||
const snapshot = makeEmptySnapshot()
|
||||
snapshot.addFile('foo', File.fromString('test: foo'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function makeTwoFileSnapshot() {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
snapshot.addFile('bar', File.fromString('test: bar'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
it('moves a file over another', function () {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'bar')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(1)
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: foo')
|
||||
})
|
||||
|
||||
it('moves a file to another pathname', function () {
|
||||
const snapshot = makeTwoFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'a')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(2)
|
||||
expect(snapshot.getFile('a').getContent()).to.equal('test: foo')
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: bar')
|
||||
})
|
||||
|
||||
it('should keep v2DocVersions in-sync', function () {
|
||||
const snapshot = makeTwoFileSnapshot()
|
||||
snapshot.setV2DocVersions(
|
||||
V2DocVersions.fromRaw({
|
||||
id1: { pathname: 'foo', v: 1 },
|
||||
id2: { pathname: 'bar', v: 1 },
|
||||
})
|
||||
)
|
||||
Operation.moveFile('foo', 'foo-after').applyTo(snapshot)
|
||||
Operation.editFile(
|
||||
'foo-after',
|
||||
TextOperation.fromJSON({ textOperation: [9, 'edit'] })
|
||||
).applyTo(snapshot)
|
||||
Operation.removeFile('bar').applyTo(snapshot)
|
||||
expect(snapshot.getV2DocVersions().toRaw()).to.deep.equal({
|
||||
id1: { pathname: 'foo-after', v: 1 },
|
||||
})
|
||||
})
|
||||
})
|
1074
libraries/overleaf-editor-core/test/operation.test.js
Normal file
1074
libraries/overleaf-editor-core/test/operation.test.js
Normal file
File diff suppressed because it is too large
Load Diff
452
libraries/overleaf-editor-core/test/range.test.js
Normal file
452
libraries/overleaf-editor-core/test/range.test.js
Normal file
@@ -0,0 +1,452 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const Range = require('../lib/range')
|
||||
|
||||
describe('Range', function () {
|
||||
it('should create a range', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(from5to14.start).to.eql(5)
|
||||
expect(from5to14.end).to.eql(15)
|
||||
})
|
||||
|
||||
it('should create a range using fromRaw', function () {
|
||||
const from5to14 = Range.fromRaw({ pos: 5, length: 10 })
|
||||
expect(from5to14.start).to.eql(5)
|
||||
expect(from5to14.end).to.eql(15)
|
||||
})
|
||||
|
||||
it('should convert to raw', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(from5to14.toRaw()).to.eql({ pos: 5, length: 10 })
|
||||
})
|
||||
|
||||
it('should check isEmpty method', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(from5to14.isEmpty()).to.be.false
|
||||
|
||||
const range0length = new Range(5, 0)
|
||||
expect(range0length.isEmpty()).to.be.true
|
||||
})
|
||||
|
||||
it('should not create a range with a negative position', function () {
|
||||
expect(() => new Range(-1, 10)).to.throw
|
||||
})
|
||||
|
||||
it('should not create a range with a negative length', function () {
|
||||
expect(() => new Range(0, -2)).to.throw
|
||||
})
|
||||
|
||||
describe('overlaps', function () {
|
||||
it('same ranges should overlap', function () {
|
||||
const range1 = new Range(1, 3)
|
||||
const range2 = new Range(1, 3)
|
||||
expect(range1.overlaps(range2)).to.eql(true)
|
||||
})
|
||||
|
||||
it('non-touching ranges should not overlap', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from10to12 = new Range(10, 3)
|
||||
expect(from1to3.overlaps(from10to12)).to.eql(false)
|
||||
expect(from10to12.overlaps(from1to3)).to.eql(false)
|
||||
})
|
||||
|
||||
it('touching ranges should not overlap', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from4to6 = new Range(4, 3)
|
||||
expect(from1to3.overlaps(from4to6)).to.eql(false)
|
||||
expect(from4to6.overlaps(from1to3)).to.eql(false)
|
||||
})
|
||||
|
||||
it('should overlap', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from2to4 = new Range(2, 3)
|
||||
expect(from1to3.overlaps(from2to4)).to.eql(true)
|
||||
expect(from2to4.overlaps(from1to3)).to.eql(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touches', function () {
|
||||
it('should not touch if ranges are the same', function () {
|
||||
const range1 = new Range(1, 3)
|
||||
const range2 = new Range(1, 3)
|
||||
expect(range1.touches(range2)).to.eql(false)
|
||||
expect(range2.touches(range1)).to.eql(false)
|
||||
})
|
||||
|
||||
it('should return true when ranges touch at one point', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from4to5 = new Range(4, 2)
|
||||
expect(from1to3.touches(from4to5)).to.eql(true)
|
||||
expect(from4to5.touches(from1to3)).to.eql(true)
|
||||
})
|
||||
|
||||
it('should return false when ranges do not touch', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from5to6 = new Range(5, 2)
|
||||
expect(from1to3.touches(from5to6)).to.eql(false)
|
||||
expect(from5to6.touches(from1to3)).to.eql(false)
|
||||
})
|
||||
|
||||
it('should return false when ranges overlap', function () {
|
||||
const from1to3 = new Range(1, 3)
|
||||
const from3to4 = new Range(3, 2)
|
||||
expect(from1to3.touches(from3to4)).to.eql(false)
|
||||
expect(from3to4.touches(from1to3)).to.eql(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should check if range contains another', function () {
|
||||
const from0to2 = new Range(0, 3)
|
||||
const from4to13 = new Range(4, 10)
|
||||
const from4to14 = new Range(4, 11)
|
||||
const from4to15 = new Range(4, 12)
|
||||
const from5to13 = new Range(5, 9)
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from5to15 = new Range(5, 11)
|
||||
const from0to99 = new Range(0, 100)
|
||||
|
||||
expect(from0to2.contains(from0to2)).to.eql(true)
|
||||
expect(from0to2.contains(from4to13)).to.eql(false)
|
||||
expect(from0to2.contains(from4to14)).to.eql(false)
|
||||
expect(from0to2.contains(from4to15)).to.eql(false)
|
||||
expect(from0to2.contains(from5to13)).to.eql(false)
|
||||
expect(from0to2.contains(from5to14)).to.eql(false)
|
||||
expect(from0to2.contains(from5to15)).to.eql(false)
|
||||
expect(from0to2.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from4to13.contains(from0to2)).to.eql(false)
|
||||
expect(from4to13.contains(from4to13)).to.eql(true)
|
||||
expect(from4to13.contains(from4to14)).to.eql(false)
|
||||
expect(from4to13.contains(from4to15)).to.eql(false)
|
||||
expect(from4to13.contains(from5to13)).to.eql(true)
|
||||
expect(from4to13.contains(from5to14)).to.eql(false)
|
||||
expect(from4to13.contains(from5to15)).to.eql(false)
|
||||
expect(from4to13.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from4to14.contains(from0to2)).to.eql(false)
|
||||
expect(from4to14.contains(from4to13)).to.eql(true)
|
||||
expect(from4to14.contains(from4to14)).to.eql(true)
|
||||
expect(from4to14.contains(from4to15)).to.eql(false)
|
||||
expect(from4to14.contains(from5to13)).to.eql(true)
|
||||
expect(from4to14.contains(from5to14)).to.eql(true)
|
||||
expect(from4to14.contains(from5to15)).to.eql(false)
|
||||
expect(from4to14.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from4to15.contains(from0to2)).to.eql(false)
|
||||
expect(from4to15.contains(from4to13)).to.eql(true)
|
||||
expect(from4to15.contains(from4to14)).to.eql(true)
|
||||
expect(from4to15.contains(from4to15)).to.eql(true)
|
||||
expect(from4to15.contains(from5to13)).to.eql(true)
|
||||
expect(from4to15.contains(from5to14)).to.eql(true)
|
||||
expect(from4to15.contains(from5to15)).to.eql(true)
|
||||
expect(from4to15.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from5to13.contains(from0to2)).to.eql(false)
|
||||
expect(from5to13.contains(from4to13)).to.eql(false)
|
||||
expect(from5to13.contains(from4to14)).to.eql(false)
|
||||
expect(from5to13.contains(from4to15)).to.eql(false)
|
||||
expect(from5to13.contains(from5to13)).to.eql(true)
|
||||
expect(from5to13.contains(from5to14)).to.eql(false)
|
||||
expect(from5to13.contains(from5to15)).to.eql(false)
|
||||
expect(from5to13.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from5to14.contains(from0to2)).to.eql(false)
|
||||
expect(from5to14.contains(from4to13)).to.eql(false)
|
||||
expect(from5to14.contains(from4to14)).to.eql(false)
|
||||
expect(from5to14.contains(from4to15)).to.eql(false)
|
||||
expect(from5to14.contains(from5to13)).to.eql(true)
|
||||
expect(from5to14.contains(from5to14)).to.eql(true)
|
||||
expect(from5to14.contains(from5to15)).to.eql(false)
|
||||
expect(from5to14.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from5to15.contains(from0to2)).to.eql(false)
|
||||
expect(from5to15.contains(from4to13)).to.eql(false)
|
||||
expect(from5to15.contains(from4to14)).to.eql(false)
|
||||
expect(from5to15.contains(from4to15)).to.eql(false)
|
||||
expect(from5to15.contains(from5to13)).to.eql(true)
|
||||
expect(from5to15.contains(from5to14)).to.eql(true)
|
||||
expect(from5to15.contains(from5to15)).to.eql(true)
|
||||
expect(from5to15.contains(from0to99)).to.eql(false)
|
||||
|
||||
expect(from0to99.contains(from0to2)).to.eql(true)
|
||||
expect(from0to99.contains(from4to13)).to.eql(true)
|
||||
expect(from0to99.contains(from4to14)).to.eql(true)
|
||||
expect(from0to99.contains(from4to15)).to.eql(true)
|
||||
expect(from0to99.contains(from5to13)).to.eql(true)
|
||||
expect(from0to99.contains(from5to14)).to.eql(true)
|
||||
expect(from0to99.contains(from5to15)).to.eql(true)
|
||||
expect(from0to99.contains(from0to99)).to.eql(true)
|
||||
})
|
||||
|
||||
it('should check if range contains a cursor', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(from5to14.containsCursor(4)).to.eql(false)
|
||||
expect(from5to14.containsCursor(5)).to.eql(true)
|
||||
expect(from5to14.containsCursor(6)).to.eql(true)
|
||||
expect(from5to14.containsCursor(14)).to.eql(true)
|
||||
expect(from5to14.containsCursor(15)).to.eql(true)
|
||||
expect(from5to14.containsCursor(16)).to.eql(false)
|
||||
})
|
||||
|
||||
describe('subtract range from another', function () {
|
||||
it('should not subtract', function () {
|
||||
const from1to5 = new Range(1, 6)
|
||||
const from0to1 = new Range(0, 1)
|
||||
const subtracted = from1to5.subtract(from0to1)
|
||||
expect(subtracted.start).to.eql(1)
|
||||
expect(subtracted.length).to.eql(6)
|
||||
})
|
||||
|
||||
it('should subtract from the left', function () {
|
||||
const from5to19 = new Range(5, 15)
|
||||
const from15to24 = new Range(15, 10)
|
||||
const subtracted = from15to24.subtract(from5to19)
|
||||
expect(subtracted.start).to.eql(5)
|
||||
expect(subtracted.end).to.eql(10)
|
||||
})
|
||||
|
||||
it('should subtract from the right', function () {
|
||||
const from10to24 = new Range(10, 15)
|
||||
const from5to19 = new Range(5, 15)
|
||||
const subtracted = from5to19.subtract(from10to24)
|
||||
expect(subtracted.start).to.eql(5)
|
||||
expect(subtracted.end).to.eql(10)
|
||||
})
|
||||
|
||||
it('should subtract from the middle', function () {
|
||||
const from5to19 = new Range(5, 15)
|
||||
const from10to14 = new Range(10, 5)
|
||||
const subtracted = from5to19.subtract(from10to14)
|
||||
expect(subtracted.start).to.eql(5)
|
||||
expect(subtracted.end).to.eql(15)
|
||||
})
|
||||
|
||||
it('should delete entire range', function () {
|
||||
const from0to99 = new Range(0, 100)
|
||||
const from5to19 = new Range(5, 15)
|
||||
const subtracted = from5to19.subtract(from0to99)
|
||||
expect(subtracted.start).to.eql(5)
|
||||
expect(subtracted.end).to.eql(5)
|
||||
expect(subtracted.length).to.eql(0)
|
||||
})
|
||||
|
||||
it('should not subtract if ranges do not overlap', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from20to29 = new Range(20, 10)
|
||||
const subtracted1 = from5to14.subtract(from20to29)
|
||||
const subtracted2 = from20to29.subtract(from5to14)
|
||||
expect(subtracted1.toRaw()).deep.equal(from5to14.toRaw())
|
||||
expect(subtracted2.toRaw()).deep.equal(from20to29.toRaw())
|
||||
})
|
||||
})
|
||||
|
||||
describe('merge ranges', function () {
|
||||
it('should merge ranges overlaping at the end', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from10to19 = new Range(10, 10)
|
||||
expect(from5to14.canMerge(from10to19)).to.eql(true)
|
||||
const result = from5to14.merge(from10to19)
|
||||
expect(result.start).to.eql(5)
|
||||
expect(result.end).to.eql(20)
|
||||
})
|
||||
|
||||
it('should merge ranges overlaping at the start', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from0to9 = new Range(0, 10)
|
||||
expect(from5to14.canMerge(from0to9)).to.eql(true)
|
||||
const result = from5to14.merge(from0to9)
|
||||
expect(result.start).to.eql(0)
|
||||
expect(result.end).to.eql(15)
|
||||
})
|
||||
|
||||
it('should merge ranges if one is covered by another', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from0to19 = new Range(0, 20)
|
||||
expect(from5to14.canMerge(from0to19)).to.eql(true)
|
||||
const result = from5to14.merge(from0to19)
|
||||
expect(result.toRaw()).deep.equal(from0to19.toRaw())
|
||||
})
|
||||
|
||||
it('should produce the same length after merge', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from0to19 = new Range(0, 20)
|
||||
expect(from0to19.canMerge(from5to14)).to.eql(true)
|
||||
const result = from0to19.merge(from5to14)
|
||||
expect(result.start).to.eql(0)
|
||||
expect(result.end).to.eql(20)
|
||||
})
|
||||
|
||||
it('should not merge ranges if they do not overlap', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const from20to29 = new Range(20, 10)
|
||||
expect(from5to14.canMerge(from20to29)).to.eql(false)
|
||||
expect(from20to29.canMerge(from5to14)).to.eql(false)
|
||||
expect(() => from5to14.merge(from20to29)).to.throw()
|
||||
})
|
||||
})
|
||||
|
||||
it('should check if range starts after a range', function () {
|
||||
const from0to4 = new Range(0, 5)
|
||||
const from1to5 = new Range(1, 5)
|
||||
const from5to9 = new Range(5, 5)
|
||||
const from6to10 = new Range(6, 5)
|
||||
const from10to14 = new Range(10, 5)
|
||||
|
||||
expect(from0to4.startsAfter(from0to4)).to.eql(false)
|
||||
expect(from0to4.startsAfter(from1to5)).to.eql(false)
|
||||
expect(from0to4.startsAfter(from5to9)).to.eql(false)
|
||||
expect(from0to4.startsAfter(from6to10)).to.eql(false)
|
||||
expect(from0to4.startsAfter(from10to14)).to.eql(false)
|
||||
|
||||
expect(from1to5.startsAfter(from0to4)).to.eql(false)
|
||||
expect(from1to5.startsAfter(from1to5)).to.eql(false)
|
||||
expect(from1to5.startsAfter(from5to9)).to.eql(false)
|
||||
expect(from1to5.startsAfter(from6to10)).to.eql(false)
|
||||
expect(from1to5.startsAfter(from10to14)).to.eql(false)
|
||||
|
||||
expect(from5to9.startsAfter(from0to4)).to.eql(true)
|
||||
expect(from5to9.startsAfter(from1to5)).to.eql(false)
|
||||
expect(from5to9.startsAfter(from5to9)).to.eql(false)
|
||||
expect(from5to9.startsAfter(from6to10)).to.eql(false)
|
||||
expect(from5to9.startsAfter(from10to14)).to.eql(false)
|
||||
|
||||
expect(from6to10.startsAfter(from0to4)).to.eql(true)
|
||||
expect(from6to10.startsAfter(from1to5)).to.eql(true)
|
||||
expect(from6to10.startsAfter(from5to9)).to.eql(false)
|
||||
expect(from6to10.startsAfter(from6to10)).to.eql(false)
|
||||
expect(from6to10.startsAfter(from10to14)).to.eql(false)
|
||||
|
||||
expect(from10to14.startsAfter(from0to4)).to.eql(true)
|
||||
expect(from10to14.startsAfter(from1to5)).to.eql(true)
|
||||
expect(from10to14.startsAfter(from5to9)).to.eql(true)
|
||||
expect(from10to14.startsAfter(from6to10)).to.eql(false)
|
||||
expect(from10to14.startsAfter(from10to14)).to.eql(false)
|
||||
})
|
||||
|
||||
it('should check if range starts after a position', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(from5to14.startIsAfter(3)).to.be.true
|
||||
expect(from5to14.startIsAfter(4)).to.be.true
|
||||
expect(from5to14.startIsAfter(5)).to.be.false
|
||||
expect(from5to14.startIsAfter(6)).to.be.false
|
||||
expect(from5to14.startIsAfter(15)).to.be.false
|
||||
expect(from5to14.startIsAfter(16)).to.be.false
|
||||
})
|
||||
|
||||
it('should extend the range', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const result = from5to14.extendBy(3)
|
||||
expect(result.length).to.eql(13)
|
||||
expect(result.start).to.eql(5)
|
||||
expect(result.end).to.eql(18)
|
||||
})
|
||||
|
||||
it('should shrink the range', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const result = from5to14.shrinkBy(3)
|
||||
expect(result.length).to.eql(7)
|
||||
expect(result.start).to.eql(5)
|
||||
expect(result.end).to.eql(12)
|
||||
})
|
||||
|
||||
it('should throw if shrinking too much', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
expect(() => from5to14.shrinkBy(11)).to.throw()
|
||||
})
|
||||
|
||||
it('should move the range', function () {
|
||||
const from5to14 = new Range(5, 10)
|
||||
const result = from5to14.moveBy(3)
|
||||
expect(result.length).to.eql(10)
|
||||
expect(result.start).to.eql(8)
|
||||
expect(result.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()
|
||||
})
|
||||
})
|
||||
})
|
126
libraries/overleaf-editor-core/test/safe_pathname.test.js
Normal file
126
libraries/overleaf-editor-core/test/safe_pathname.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const safePathname = ot.safePathname
|
||||
|
||||
describe('safePathname', function () {
|
||||
function expectClean(input, output, reason = '') {
|
||||
// check expected output and also idempotency
|
||||
const [cleanedInput, gotReason] = safePathname.cleanDebug(input)
|
||||
expect(cleanedInput).to.equal(output)
|
||||
expect(gotReason).to.equal(reason)
|
||||
expect(safePathname.clean(cleanedInput)).to.equal(cleanedInput)
|
||||
expect(safePathname.isClean(cleanedInput)).to.be.true
|
||||
}
|
||||
|
||||
it('cleans pathnames', function () {
|
||||
// preserve valid pathnames
|
||||
expectClean('llama.jpg', 'llama.jpg')
|
||||
expectClean('DSC4056.JPG', 'DSC4056.JPG')
|
||||
|
||||
// detects unclean pathnames
|
||||
expect(safePathname.isClean('rm -rf /')).to.be.falsy
|
||||
|
||||
// replace invalid characters with underscores
|
||||
expectClean(
|
||||
'test-s*\u0001\u0002m\u0007st\u0008.jpg',
|
||||
'test-s___m_st_.jpg',
|
||||
'cleanPart'
|
||||
)
|
||||
|
||||
// keep slashes, normalize paths, replace ..
|
||||
expectClean('./foo', 'foo', 'normalize')
|
||||
expectClean('../foo', '__/foo', 'cleanPart')
|
||||
expectClean('foo/./bar', 'foo/bar', 'normalize')
|
||||
expectClean('foo/../bar', 'bar', 'normalize')
|
||||
expectClean('../../tricky/foo.bar', '__/__/tricky/foo.bar', 'cleanPart')
|
||||
expectClean(
|
||||
'foo/../../tricky/foo.bar',
|
||||
'__/tricky/foo.bar',
|
||||
'normalize,cleanPart'
|
||||
)
|
||||
expectClean('foo/bar/../../tricky/foo.bar', 'tricky/foo.bar', 'normalize')
|
||||
expectClean(
|
||||
'foo/bar/baz/../../tricky/foo.bar',
|
||||
'foo/tricky/foo.bar',
|
||||
'normalize'
|
||||
)
|
||||
|
||||
// remove illegal chars even when there is no extension
|
||||
expectClean('**foo', '__foo', 'cleanPart')
|
||||
|
||||
// remove windows file paths
|
||||
expectClean('c:\\temp\\foo.txt', 'c:/temp/foo.txt', 'workaround for IE')
|
||||
|
||||
// do not allow a leading slash (relative paths only)
|
||||
expectClean('/foo', '_/foo', 'no leading /')
|
||||
expectClean('//foo', '_/foo', 'normalize,no leading /')
|
||||
|
||||
// do not allow multiple leading slashes
|
||||
expectClean('//foo', '_/foo', 'normalize,no leading /')
|
||||
|
||||
// do not allow a trailing slash
|
||||
expectClean('/', '_', 'no leading /,no trailing /')
|
||||
expectClean('foo/', 'foo', 'no trailing /')
|
||||
expectClean('foo.tex/', 'foo.tex', 'no trailing /')
|
||||
|
||||
// do not allow multiple trailing slashes
|
||||
expectClean('//', '_', 'normalize,no leading /,no trailing /')
|
||||
expectClean('///', '_', 'normalize,no leading /,no trailing /')
|
||||
expectClean('foo//', 'foo', 'normalize,no trailing /')
|
||||
|
||||
// file and folder names that consist of . and .. are not OK
|
||||
expectClean('.', '_', 'cleanPart')
|
||||
expectClean('..', '__', 'cleanPart')
|
||||
// we will allow name with more dots e.g. ... and ....
|
||||
expectClean('...', '...')
|
||||
expectClean('....', '....')
|
||||
expectClean('foo/...', 'foo/...')
|
||||
expectClean('foo/....', 'foo/....')
|
||||
expectClean('foo/.../bar', 'foo/.../bar')
|
||||
expectClean('foo/..../bar', 'foo/..../bar')
|
||||
|
||||
// leading dots are OK
|
||||
expectClean('._', '._')
|
||||
expectClean('.gitignore', '.gitignore')
|
||||
|
||||
// trailing dots are not OK on Windows but we allow them
|
||||
expectClean('_.', '_.')
|
||||
expectClean('foo/_.', 'foo/_.')
|
||||
expectClean('foo/_./bar', 'foo/_./bar')
|
||||
expectClean('foo/_../bar', 'foo/_../bar')
|
||||
|
||||
// spaces are allowed
|
||||
expectClean('a b.png', 'a b.png')
|
||||
|
||||
// leading and trailing spaces are not OK
|
||||
expectClean(' foo', 'foo', 'no leading spaces')
|
||||
expectClean(' foo', 'foo', 'no leading spaces')
|
||||
expectClean('foo ', 'foo', 'no trailing spaces')
|
||||
expectClean('foo ', 'foo', 'no trailing spaces')
|
||||
|
||||
// reserved file names on Windows should not be OK, but we already have
|
||||
// some in the old system, so have to allow them for now
|
||||
expectClean('AUX', 'AUX')
|
||||
expectClean('foo/AUX', 'foo/AUX')
|
||||
expectClean('AUX/foo', 'AUX/foo')
|
||||
|
||||
// multiple dots are OK
|
||||
expectClean('a.b.png', 'a.b.png')
|
||||
expectClean('a.code.tex', 'a.code.tex')
|
||||
|
||||
// there's no particular reason to allow multiple slashes; sometimes people
|
||||
// seem to rename files to URLs (https://domain/path) in an attempt to
|
||||
// upload a file, and this results in an empty directory name
|
||||
expectClean('foo//bar.png', 'foo/bar.png', 'normalize')
|
||||
expectClean('foo///bar.png', 'foo/bar.png', 'normalize')
|
||||
|
||||
// Check javascript property handling
|
||||
expectClean('foo/prototype', 'foo/prototype') // OK as part of a pathname
|
||||
expectClean('prototype/test.txt', 'prototype/test.txt')
|
||||
expectClean('prototype', '@prototype', 'BLOCKED_FILE_RX') // not OK as whole pathname
|
||||
expectClean('hasOwnProperty', '@hasOwnProperty', 'BLOCKED_FILE_RX')
|
||||
expectClean('**proto**', '@__proto__', 'cleanPart,BLOCKED_FILE_RX')
|
||||
})
|
||||
})
|
477
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
477
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
@@ -0,0 +1,477 @@
|
||||
// @ts-check
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
RetainOp,
|
||||
ScanOp,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
} = require('../lib/operation/scan_op')
|
||||
const { UnprocessableError, ApplyError } = require('../lib/errors')
|
||||
const TrackingProps = require('../lib/file_data/tracking_props')
|
||||
|
||||
describe('ScanOp', function () {
|
||||
describe('fromJSON', function () {
|
||||
it('constructs a RetainOp from object', function () {
|
||||
const op = ScanOp.fromJSON({ r: 1 })
|
||||
expect(op).to.be.instanceOf(RetainOp)
|
||||
expect(/** @type {RetainOp} */ (op).length).to.equal(1)
|
||||
})
|
||||
|
||||
it('constructs a RetainOp from number', function () {
|
||||
const op = ScanOp.fromJSON(2)
|
||||
expect(op).to.be.instanceOf(RetainOp)
|
||||
expect(/** @type {RetainOp} */ (op).length).to.equal(2)
|
||||
})
|
||||
|
||||
it('constructs an InsertOp from string', function () {
|
||||
const op = ScanOp.fromJSON('abc')
|
||||
expect(op).to.be.instanceOf(InsertOp)
|
||||
expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc')
|
||||
})
|
||||
|
||||
it('constructs an InsertOp from object', function () {
|
||||
const op = ScanOp.fromJSON({ i: 'abc' })
|
||||
expect(op).to.be.instanceOf(InsertOp)
|
||||
expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc')
|
||||
})
|
||||
|
||||
it('constructs a RemoveOp from number', function () {
|
||||
const op = ScanOp.fromJSON(-2)
|
||||
expect(op).to.be.instanceOf(RemoveOp)
|
||||
expect(/** @type {RemoveOp} */ (op).length).to.equal(2)
|
||||
})
|
||||
|
||||
it('throws an error for invalid input', function () {
|
||||
expect(() => ScanOp.fromJSON(/** @type {any} */ ({}))).to.throw(
|
||||
UnprocessableError
|
||||
)
|
||||
})
|
||||
|
||||
it('throws an error for zero', function () {
|
||||
expect(() => ScanOp.fromJSON(0)).to.throw(UnprocessableError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RetainOp', function () {
|
||||
it('is equal to another RetainOp with the same length', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new RetainOp(1)
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
})
|
||||
|
||||
it('is not equal to another RetainOp with a different length', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new RetainOp(2)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another RetainOp with no tracking info', function () {
|
||||
const op1 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new RetainOp(4)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another RetainOp with different tracking info', function () {
|
||||
const op1 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user2', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to an InsertOp', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to a RemoveOp', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new RemoveOp(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('can merge with another RetainOp', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new RetainOp(2)
|
||||
expect(op1.canMergeWith(op2)).to.be.true
|
||||
op1.mergeWith(op2)
|
||||
expect(op1.equals(new RetainOp(3))).to.be.true
|
||||
})
|
||||
|
||||
it('cannot merge with another RetainOp if tracking info is different', function () {
|
||||
const op1 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user2', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('can merge with another RetainOp if tracking info is the same', function () {
|
||||
const op1 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new RetainOp(
|
||||
4,
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
op1.mergeWith(op2)
|
||||
expect(
|
||||
op1.equals(
|
||||
new RetainOp(
|
||||
8,
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
)
|
||||
)
|
||||
)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('cannot merge with an InsertOp', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with a RemoveOp', function () {
|
||||
const op1 = new RetainOp(1)
|
||||
const op2 = new RemoveOp(1)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('can be converted to JSON', function () {
|
||||
const op = new RetainOp(3)
|
||||
expect(op.toJSON()).to.equal(3)
|
||||
})
|
||||
|
||||
it('adds to the length and cursor when applied to length', function () {
|
||||
const op = new RetainOp(3)
|
||||
const { length, inputCursor } = op.applyToLength({
|
||||
length: 10,
|
||||
inputCursor: 10,
|
||||
inputLength: 30,
|
||||
})
|
||||
expect(length).to.equal(13)
|
||||
expect(inputCursor).to.equal(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('InsertOp', function () {
|
||||
it('is equal to another InsertOp with the same insertion', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with a different insertion', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new InsertOp('b')
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with no tracking info', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with different tracking info', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user2', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with no comment ids', function () {
|
||||
const op1 = new InsertOp('a', undefined, ['1'])
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with tracking info', function () {
|
||||
const op1 = new InsertOp('a', undefined)
|
||||
const op2 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with comment ids', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new InsertOp('a', undefined, ['1'])
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with different comment ids', function () {
|
||||
const op1 = new InsertOp('a', undefined, ['1'])
|
||||
const op2 = new InsertOp('a', undefined, ['2'])
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to another InsertOp with overlapping comment ids', function () {
|
||||
const op1 = new InsertOp('a', undefined, ['1'])
|
||||
const op2 = new InsertOp('a', undefined, ['2', '1'])
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to a RetainOp', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new RetainOp(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to a RemoveOp', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new RemoveOp(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('can merge with another InsertOp', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new InsertOp('b')
|
||||
expect(op1.canMergeWith(op2)).to.be.true
|
||||
op1.mergeWith(op2)
|
||||
expect(op1.equals(new InsertOp('ab'))).to.be.true
|
||||
})
|
||||
|
||||
it('cannot merge with another InsertOp if comment id info is different', function () {
|
||||
const op1 = new InsertOp('a', undefined, ['1'])
|
||||
const op2 = new InsertOp('b', undefined, ['1', '2'])
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with another InsertOp if comment id info is different while tracking info matches', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['1', '2']
|
||||
)
|
||||
const op2 = new InsertOp(
|
||||
'b',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['3']
|
||||
)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with another InsertOp if comment id is present in other and tracking info matches', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new InsertOp(
|
||||
'b',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['1']
|
||||
)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with another InsertOp if tracking info is different', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
const op2 = new InsertOp(
|
||||
'b',
|
||||
new TrackingProps('insert', 'user2', new Date('2024-01-01T00:00:00.000Z'))
|
||||
)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('can merge with another InsertOp if tracking and comment info is the same', function () {
|
||||
const op1 = new InsertOp(
|
||||
'a',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['1', '2']
|
||||
)
|
||||
const op2 = new InsertOp(
|
||||
'b',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['1', '2']
|
||||
)
|
||||
expect(op1.canMergeWith(op2)).to.be.true
|
||||
op1.mergeWith(op2)
|
||||
expect(
|
||||
op1.equals(
|
||||
new InsertOp(
|
||||
'ab',
|
||||
new TrackingProps(
|
||||
'insert',
|
||||
'user1',
|
||||
new Date('2024-01-01T00:00:00.000Z')
|
||||
),
|
||||
['1', '2']
|
||||
)
|
||||
)
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it('cannot merge with a RetainOp', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new RetainOp(1)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with a RemoveOp', function () {
|
||||
const op1 = new InsertOp('a')
|
||||
const op2 = new RemoveOp(1)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('can be converted to JSON', function () {
|
||||
const op = new InsertOp('a')
|
||||
expect(op.toJSON()).to.equal('a')
|
||||
})
|
||||
|
||||
it('adds to the length when applied to length', function () {
|
||||
const op = new InsertOp('abc')
|
||||
const { length, inputCursor } = op.applyToLength({
|
||||
length: 10,
|
||||
inputCursor: 20,
|
||||
inputLength: 40,
|
||||
})
|
||||
expect(length).to.equal(13)
|
||||
expect(inputCursor).to.equal(20)
|
||||
})
|
||||
|
||||
it('can apply a retain of the rest of the input', function () {
|
||||
const op = new RetainOp(10)
|
||||
const { length, inputCursor } = op.applyToLength({
|
||||
length: 10,
|
||||
inputCursor: 5,
|
||||
inputLength: 15,
|
||||
})
|
||||
expect(length).to.equal(20)
|
||||
expect(inputCursor).to.equal(15)
|
||||
})
|
||||
|
||||
it('cannot apply to length if the input cursor is at the end', function () {
|
||||
const op = new RetainOp(10)
|
||||
expect(() =>
|
||||
op.applyToLength({
|
||||
length: 10,
|
||||
inputCursor: 10,
|
||||
inputLength: 10,
|
||||
})
|
||||
).to.throw(ApplyError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoveOp', function () {
|
||||
it('is equal to another RemoveOp with the same length', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new RemoveOp(1)
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
})
|
||||
|
||||
it('is not equal to another RemoveOp with a different length', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new RemoveOp(2)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to a RetainOp', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new RetainOp(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('is not equal to an InsertOp', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('can merge with another RemoveOp', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new RemoveOp(2)
|
||||
expect(op1.canMergeWith(op2)).to.be.true
|
||||
op1.mergeWith(op2)
|
||||
expect(op1.equals(new RemoveOp(3))).to.be.true
|
||||
})
|
||||
|
||||
it('cannot merge with a RetainOp', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new RetainOp(1)
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('cannot merge with an InsertOp', function () {
|
||||
const op1 = new RemoveOp(1)
|
||||
const op2 = new InsertOp('a')
|
||||
expect(op1.canMergeWith(op2)).to.be.false
|
||||
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||
})
|
||||
|
||||
it('can be converted to JSON', function () {
|
||||
const op = new RemoveOp(3)
|
||||
expect(op.toJSON()).to.equal(-3)
|
||||
})
|
||||
|
||||
it('adds to the input cursor when applied to length', function () {
|
||||
const op = new RemoveOp(3)
|
||||
const { length, inputCursor } = op.applyToLength({
|
||||
length: 10,
|
||||
inputCursor: 10,
|
||||
inputLength: 30,
|
||||
})
|
||||
expect(length).to.equal(10)
|
||||
expect(inputCursor).to.equal(13)
|
||||
})
|
||||
})
|
92
libraries/overleaf-editor-core/test/snapshot.test.js
Normal file
92
libraries/overleaf-editor-core/test/snapshot.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
File,
|
||||
Snapshot,
|
||||
TextOperation,
|
||||
Change,
|
||||
EditFileOperation,
|
||||
} = require('..')
|
||||
|
||||
describe('Snapshot', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from files', function () {
|
||||
const snapshot = new Snapshot()
|
||||
|
||||
const blobHashes = new Set()
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file without a hash.
|
||||
snapshot.addFile('foo', File.fromString(''))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash.
|
||||
snapshot.addFile('bar', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
})
|
||||
})
|
||||
|
||||
describe('editFile', function () {
|
||||
let snapshot
|
||||
let operation
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('hello.txt', File.fromString('hello'))
|
||||
operation = new TextOperation()
|
||||
operation.retain(5)
|
||||
operation.insert(' world!')
|
||||
})
|
||||
|
||||
it('applies text operations to the file', function () {
|
||||
snapshot.editFile('hello.txt', operation)
|
||||
const file = snapshot.getFile('hello.txt')
|
||||
expect(file.getContent()).to.equal('hello world!')
|
||||
})
|
||||
|
||||
it('rejects text operations for nonexistent file', function () {
|
||||
expect(() => {
|
||||
snapshot.editFile('does-not-exist.txt', operation)
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAll', function () {
|
||||
let snapshot
|
||||
let change
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('empty.txt', File.fromString(''))
|
||||
const badTextOp = new TextOperation()
|
||||
badTextOp.insert('FAIL!')
|
||||
const goodTextOp = new TextOperation()
|
||||
goodTextOp.insert('SUCCESS!')
|
||||
change = new Change(
|
||||
[
|
||||
new EditFileOperation('missing.txt', badTextOp),
|
||||
new EditFileOperation('empty.txt', goodTextOp),
|
||||
],
|
||||
new Date()
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores recoverable errors', function () {
|
||||
snapshot.applyAll([change])
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('SUCCESS!')
|
||||
})
|
||||
|
||||
it('stops on recoverable errors in strict mode', function () {
|
||||
expect(() => {
|
||||
snapshot.applyAll([change], { strict: true })
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
167
libraries/overleaf-editor-core/test/string_file_data.test.js
Normal file
167
libraries/overleaf-editor-core/test/string_file_data.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('StringFileData', function () {
|
||||
it('throws when it contains non BMP chars', function () {
|
||||
const content = '𝌆𝌆𝌆'
|
||||
const fileData = new StringFileData(content)
|
||||
const operation = new TextOperation()
|
||||
operation.insert('aa')
|
||||
expect(() => {
|
||||
fileData.edit(operation)
|
||||
}).to.throw(TextOperation.ApplyError, /string contains non BMP characters/)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
|
||||
const fileData = new StringFileData(longString)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
fileData.edit(new TextOperation().retain(longString.length - 1).remove(1))
|
||||
expect(fileData.getByteLength()).to.equal(longString.length - 1)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length - 1)
|
||||
})
|
||||
|
||||
it('getComments() should return an empty array', function () {
|
||||
const fileData = new StringFileData('test')
|
||||
expect(fileData.getComments().toRaw()).to.eql([])
|
||||
})
|
||||
|
||||
it('creates StringFileData with comments', function () {
|
||||
const fileData = new StringFileData('test', [
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [
|
||||
{
|
||||
pos: 5,
|
||||
length: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [
|
||||
{
|
||||
pos: 20,
|
||||
length: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('fromRaw() should create StringFileData with comments', function () {
|
||||
const fileData = StringFileData.fromRaw({
|
||||
content: 'test',
|
||||
comments: [
|
||||
{
|
||||
id: 'comm1',
|
||||
ranges: [
|
||||
{
|
||||
pos: 5,
|
||||
length: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comm2',
|
||||
ranges: [
|
||||
{
|
||||
pos: 20,
|
||||
length: 5,
|
||||
},
|
||||
],
|
||||
resolved: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(fileData.getComments().toRaw()).to.eql([
|
||||
{ id: 'comm1', ranges: [{ pos: 5, length: 10 }] },
|
||||
{ id: 'comm2', ranges: [{ pos: 20, length: 5 }], resolved: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('getContent should filter out tracked deletions when passed option', function () {
|
||||
const fileData = new StringFileData(
|
||||
'the quick brown fox jumps over the lazy dog',
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
range: { pos: 4, length: 6 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 35, length: 5 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
expect(fileData.getContent()).to.equal(
|
||||
'the quick brown fox jumps over the lazy dog'
|
||||
)
|
||||
expect(fileData.getContent({ filterTrackedDeletes: true })).to.equal(
|
||||
'the brown fox jumps over the dog'
|
||||
)
|
||||
})
|
||||
|
||||
it('getContent should keep tracked insertions when passed option to remove tracked changes', function () {
|
||||
const fileData = new StringFileData(
|
||||
'the quick brown fox jumps over the lazy dog',
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
range: { pos: 4, length: 6 },
|
||||
tracking: {
|
||||
type: 'insert',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 35, length: 5 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
expect(fileData.getContent()).to.equal(
|
||||
'the quick brown fox jumps over the lazy dog'
|
||||
)
|
||||
expect(fileData.getContent({ filterTrackedDeletes: true })).to.equal(
|
||||
'the quick brown fox jumps over the dog'
|
||||
)
|
||||
})
|
||||
})
|
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @import { Blob } from "../.."
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fake blob store for tests
|
||||
*/
|
||||
class FakeBlobStore {
|
||||
/**
|
||||
* Get a string from the blob store
|
||||
*
|
||||
* @param {string} hash
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
getString(hash) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a string in the blob store
|
||||
*
|
||||
* @param {string} content
|
||||
* @return {Promise<Blob>}
|
||||
*/
|
||||
putString(content) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FakeBlobStore
|
66
libraries/overleaf-editor-core/test/support/random.js
Normal file
66
libraries/overleaf-editor-core/test/support/random.js
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// Randomised testing helpers from OT.js:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/helpers.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
function randomInt(n) {
|
||||
return Math.floor(Math.random() * n)
|
||||
}
|
||||
|
||||
function randomString(n, newLine = true) {
|
||||
let str = ''
|
||||
while (n--) {
|
||||
if (newLine && Math.random() < 0.15) {
|
||||
str += '\n'
|
||||
} else {
|
||||
const chr = randomInt(26) + 97
|
||||
str += String.fromCharCode(chr)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
function randomElement(arr) {
|
||||
return arr[randomInt(arr.length)]
|
||||
}
|
||||
|
||||
function randomTest(numTrials, test) {
|
||||
return function () {
|
||||
while (numTrials--) test()
|
||||
}
|
||||
}
|
||||
|
||||
function randomSubset(arr) {
|
||||
const n = randomInt(arr.length)
|
||||
const subset = []
|
||||
const indices = []
|
||||
for (let i = 0; i < arr.length; i++) indices.push(i)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const index = randomInt(indices.length)
|
||||
subset.push(arr[indices[index]])
|
||||
indices.splice(index, 1)
|
||||
}
|
||||
return subset
|
||||
}
|
||||
|
||||
function randomComments(number) {
|
||||
const ids = new Set()
|
||||
const comments = []
|
||||
while (comments.length < number) {
|
||||
const id = randomString(10, false)
|
||||
if (!ids.has(id)) {
|
||||
comments.push({ id, ranges: [], resolved: false })
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return { ids: Array.from(ids), comments }
|
||||
}
|
||||
|
||||
exports.int = randomInt
|
||||
exports.string = randomString
|
||||
exports.element = randomElement
|
||||
exports.test = randomTest
|
||||
exports.comments = randomComments
|
||||
exports.subset = randomSubset
|
@@ -0,0 +1,57 @@
|
||||
const TrackingProps = require('../../lib/file_data/tracking_props')
|
||||
const ClearTrackingProps = require('../../lib/file_data/clear_tracking_props')
|
||||
const TextOperation = require('../../lib/operation/text_operation')
|
||||
const random = require('./random')
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {string[]} [commentIds]
|
||||
* @returns {TextOperation}
|
||||
*/
|
||||
function randomTextOperation(str, commentIds) {
|
||||
const operation = new TextOperation()
|
||||
let left
|
||||
while (true) {
|
||||
left = str.length - operation.baseLength
|
||||
if (left === 0) break
|
||||
const r = Math.random()
|
||||
const l = 1 + random.int(Math.min(left - 1, 20))
|
||||
const trackedChange =
|
||||
Math.random() < 0.1
|
||||
? new TrackingProps(
|
||||
random.element(['insert', 'delete']),
|
||||
random.element(['user1', 'user2', 'user3']),
|
||||
new Date(
|
||||
random.element([
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'2023-01-01T00:00:00.000Z',
|
||||
'2022-01-01T00:00:00.000Z',
|
||||
])
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
if (r < 0.2) {
|
||||
let operationCommentIds
|
||||
if (commentIds?.length > 0 && Math.random() < 0.3) {
|
||||
operationCommentIds = random.subset(commentIds)
|
||||
}
|
||||
operation.insert(random.string(l), {
|
||||
tracking: trackedChange,
|
||||
commentIds: operationCommentIds,
|
||||
})
|
||||
} else if (r < 0.4) {
|
||||
operation.remove(l)
|
||||
} else if (r < 0.5) {
|
||||
operation.retain(l, { tracking: new ClearTrackingProps() })
|
||||
} else {
|
||||
operation.retain(l, { tracking: trackedChange })
|
||||
}
|
||||
}
|
||||
if (Math.random() < 0.3) {
|
||||
operation.insert(1 + random.string(10))
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
module.exports = randomTextOperation
|
879
libraries/overleaf-editor-core/test/text_operation.test.js
Normal file
879
libraries/overleaf-editor-core/test/text_operation.test.js
Normal file
@@ -0,0 +1,879 @@
|
||||
// @ts-check
|
||||
//
|
||||
// These tests are based on the OT.js tests:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const random = require('./support/random')
|
||||
const randomOperation = require('./support/random_text_operation')
|
||||
|
||||
const ot = require('..')
|
||||
const TextOperation = ot.TextOperation
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const { RetainOp, InsertOp, RemoveOp } = require('../lib/operation/scan_op')
|
||||
const TrackingProps = require('../lib/file_data/tracking_props')
|
||||
const ClearTrackingProps = require('../lib/file_data/clear_tracking_props')
|
||||
|
||||
describe('TextOperation', function () {
|
||||
const numTrials = 500
|
||||
|
||||
it('tracks base and target lengths', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.baseLength).to.equal(0)
|
||||
expect(o.targetLength).to.equal(0)
|
||||
o.retain(5)
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
o.insert('abc')
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(8)
|
||||
o.retain(2)
|
||||
expect(o.baseLength).to.equal(7)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
o.remove(2)
|
||||
expect(o.baseLength).to.equal(9)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
})
|
||||
|
||||
it('supports chaining', function () {
|
||||
const o = new TextOperation()
|
||||
.retain(5)
|
||||
.retain(0)
|
||||
.insert('lorem')
|
||||
.insert('')
|
||||
.remove('abc')
|
||||
.remove(3)
|
||||
.remove(0)
|
||||
.remove('')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
})
|
||||
|
||||
it('ignores empty operations', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(0)
|
||||
o.insert('')
|
||||
o.remove('')
|
||||
expect(o.ops.length).to.equal(0)
|
||||
})
|
||||
|
||||
it('checks for equality', function () {
|
||||
const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
|
||||
const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
op1.remove(1)
|
||||
op2.retain(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('merges ops', function () {
|
||||
function last(arr) {
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
const o = new TextOperation()
|
||||
expect(o.ops.length).to.equal(0)
|
||||
o.retain(2)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops).equals(new RetainOp(2))).to.be.true
|
||||
o.retain(3)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops).equals(new RetainOp(5))).to.be.true
|
||||
o.insert('abc')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops).equals(new InsertOp('abc'))).to.be.true
|
||||
o.insert('xyz')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops).equals(new InsertOp('abcxyz'))).to.be.true
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops).equals(new RemoveOp(1))).to.be.true
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops).equals(new RemoveOp(2))).to.be.true
|
||||
})
|
||||
|
||||
it('checks for no-ops', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(5)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(3)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.insert('lorem')
|
||||
expect(o.isNoop()).to.be.false
|
||||
})
|
||||
|
||||
it('converts to string', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(2)
|
||||
o.insert('lorem')
|
||||
o.remove('ipsum')
|
||||
o.retain(5)
|
||||
expect(o.toString()).to.equal(
|
||||
"retain 2, insert 'lorem', remove 5, retain 5"
|
||||
)
|
||||
})
|
||||
|
||||
it('converts from JSON', function () {
|
||||
const ops = [2, -1, -1, 'cde']
|
||||
const o = TextOperation.fromJSON({ textOperation: ops })
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(o.baseLength).to.equal(4)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
|
||||
function assertIncorrectAfter(fn) {
|
||||
const ops2 = ops.slice(0)
|
||||
fn(ops2)
|
||||
expect(() => {
|
||||
TextOperation.fromJSON({ textOperation: ops2 })
|
||||
}).to.throw
|
||||
}
|
||||
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push({ insert: 'x' })
|
||||
})
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push(null)
|
||||
})
|
||||
})
|
||||
|
||||
it(
|
||||
'applies (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const str = random.string(50)
|
||||
const comments = random.comments(6)
|
||||
const o = randomOperation(str, comments.ids)
|
||||
expect(str.length).to.equal(o.baseLength)
|
||||
const file = new StringFileData(str, comments.comments)
|
||||
o.apply(file)
|
||||
const result = file.getContent()
|
||||
expect(result.length).to.equal(o.targetLength)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'converts to/from JSON (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const doc = random.string(50)
|
||||
const comments = random.comments(2)
|
||||
const operation = randomOperation(doc, comments.ids)
|
||||
const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
|
||||
expect(operation.equals(roundTripOperation)).to.be.true
|
||||
})
|
||||
)
|
||||
|
||||
it('throws when invalid operations are applied', function () {
|
||||
const operation = new TextOperation().retain(1)
|
||||
expect(() => {
|
||||
operation.apply(new StringFileData(''))
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
expect(() => {
|
||||
operation.apply(new StringFileData(' '))
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('throws when insert text contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.insert(str)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when base string contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.apply(new StringFileData(str))
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/string contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws at from JSON when it contains non BMP chars', function () {
|
||||
const operation = ['𝌆\n']
|
||||
expect(() => {
|
||||
TextOperation.fromJSON({ textOperation: operation })
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
describe('invert', function () {
|
||||
it(
|
||||
'inverts (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const str = random.string(50)
|
||||
const comments = random.comments(6)
|
||||
const o = randomOperation(str, comments.ids)
|
||||
const originalFile = new StringFileData(str, comments.comments)
|
||||
const p = o.invert(originalFile)
|
||||
expect(o.baseLength).to.equal(p.targetLength)
|
||||
expect(o.targetLength).to.equal(p.baseLength)
|
||||
const file = new StringFileData(str, comments.comments)
|
||||
o.apply(file)
|
||||
p.apply(file)
|
||||
const result = file.toRaw()
|
||||
expect(result).to.deep.equal(originalFile.toRaw())
|
||||
})
|
||||
)
|
||||
|
||||
it('re-inserts removed range and comment when inverting', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData(
|
||||
'foo bar baz',
|
||||
[{ id: 'comment1', ranges: [{ pos: 4, length: 3 }] }],
|
||||
[
|
||||
{
|
||||
range: { pos: 4, length: 3 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
]
|
||||
),
|
||||
new TextOperation().retain(4).remove(4).retain(3)
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes inserted range and comment when inverting', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData('foo baz', [
|
||||
{ id: 'comment1', ranges: [], resolved: false },
|
||||
]),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('bar', {
|
||||
commentIds: ['comment1'],
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.insert(' ')
|
||||
.retain(3)
|
||||
)
|
||||
})
|
||||
|
||||
it('removes a tracked delete', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3)
|
||||
)
|
||||
})
|
||||
|
||||
it('restores comments that were removed', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData('foo bar baz', [
|
||||
{
|
||||
id: 'comment1',
|
||||
ranges: [{ pos: 4, length: 3 }],
|
||||
resolved: false,
|
||||
},
|
||||
]),
|
||||
new TextOperation().retain(4).remove(4).retain(3)
|
||||
)
|
||||
})
|
||||
|
||||
it('re-inserting removed part of comment restores original comment range', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData('foo bar baz', [
|
||||
{
|
||||
id: 'comment1',
|
||||
ranges: [{ pos: 0, length: 11 }],
|
||||
resolved: false,
|
||||
},
|
||||
]),
|
||||
new TextOperation().retain(4).remove(4).retain(3)
|
||||
)
|
||||
})
|
||||
|
||||
it('re-inserting removed part of tracked change restores tracked change range', function () {
|
||||
expectInverseToLeadToInitialState(
|
||||
new StringFileData('foo bar baz', undefined, [
|
||||
{
|
||||
range: { pos: 0, length: 11 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
]),
|
||||
new TextOperation().retain(4).remove(4).retain(3)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compose', function () {
|
||||
it(
|
||||
'composes (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
|
||||
const str = random.string(20)
|
||||
const comments = random.comments(6)
|
||||
const a = randomOperation(str, comments.ids)
|
||||
const file = new StringFileData(str, comments.comments)
|
||||
a.apply(file)
|
||||
const afterA = file.toRaw()
|
||||
expect(afterA.content.length).to.equal(a.targetLength)
|
||||
const b = randomOperation(afterA.content, comments.ids)
|
||||
b.apply(file)
|
||||
const afterB = file.toRaw()
|
||||
expect(afterB.content.length).to.equal(b.targetLength)
|
||||
const ab = a.compose(b)
|
||||
expect(ab.targetLength).to.equal(b.targetLength)
|
||||
ab.apply(new StringFileData(str, comments.comments))
|
||||
const afterAB = file.toRaw()
|
||||
expect(afterAB).to.deep.equal(afterB)
|
||||
})
|
||||
)
|
||||
|
||||
it('composes two operations with comments', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo baz', [
|
||||
{ id: 'comment1', ranges: [], resolved: false },
|
||||
]),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('bar', {
|
||||
commentIds: ['comment1'],
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.insert(' ')
|
||||
.retain(3),
|
||||
new TextOperation().retain(4).remove(4).retain(3)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo baz',
|
||||
comments: [{ id: 'comment1', ranges: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('prioritizes tracked changes info from the latter operation', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
}),
|
||||
})
|
||||
.retain(3)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not remove tracked change if not overriden by operation 2', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3),
|
||||
new TextOperation().retain(11)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('adds comment ranges from both operations', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo bar baz', [
|
||||
{
|
||||
id: 'comment1',
|
||||
ranges: [{ pos: 4, length: 3 }],
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
id: 'comment2',
|
||||
ranges: [{ pos: 8, length: 3 }],
|
||||
resolved: false,
|
||||
},
|
||||
]),
|
||||
new TextOperation()
|
||||
.retain(5)
|
||||
.insert('aa', {
|
||||
commentIds: ['comment1'],
|
||||
})
|
||||
.retain(6),
|
||||
new TextOperation()
|
||||
.retain(11)
|
||||
.insert('bb', { commentIds: ['comment2'] })
|
||||
.retain(2)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo baaar bbbaz',
|
||||
comments: [
|
||||
{ id: 'comment1', ranges: [{ pos: 4, length: 5 }] },
|
||||
{ id: 'comment2', ranges: [{ pos: 10, length: 5 }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('it removes the tracking range from a tracked delete if operation 2 resolves it', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: new ClearTrackingProps(),
|
||||
})
|
||||
.retain(3)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz',
|
||||
})
|
||||
})
|
||||
|
||||
it('it removes the tracking from an insert if operation 2 resolves it', function () {
|
||||
expect(
|
||||
compose(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('quux ', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(7),
|
||||
new TextOperation()
|
||||
.retain(6)
|
||||
.retain(5, {
|
||||
tracking: new ClearTrackingProps(),
|
||||
})
|
||||
.retain(5)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo quux bar baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 2 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform', function () {
|
||||
it(
|
||||
'transforms (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// invariant: compose(a, b') = compose(b, a')
|
||||
// where (a', b') = transform(a, b)
|
||||
const str = random.string(20)
|
||||
const comments = random.comments(6)
|
||||
const a = randomOperation(str, comments.ids)
|
||||
const b = randomOperation(str, comments.ids)
|
||||
const primes = TextOperation.transform(a, b)
|
||||
const aPrime = primes[0]
|
||||
const bPrime = primes[1]
|
||||
const abPrime = a.compose(bPrime)
|
||||
const baPrime = b.compose(aPrime)
|
||||
const abFile = new StringFileData(str, comments.comments)
|
||||
const baFile = new StringFileData(str, comments.comments)
|
||||
abPrime.apply(abFile)
|
||||
baPrime.apply(baFile)
|
||||
expect(abPrime.equals(baPrime)).to.be.true
|
||||
expect(abFile.toRaw()).to.deep.equal(baFile.toRaw())
|
||||
})
|
||||
)
|
||||
|
||||
it('adds a tracked change from operation 1', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('bar', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.insert(' ')
|
||||
.retain(3),
|
||||
new TextOperation().retain(7).insert(' qux')
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz qux',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 3 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('prioritizes tracked change from the first operation', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
}),
|
||||
})
|
||||
.retain(3)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('splits a tracked change in two to resolve conflicts', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(4, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(3),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.retain(5, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
}),
|
||||
})
|
||||
.retain(2)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo bar baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 8, length: 1 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('inserts a tracked change from operation 2 after a tracked change from operation 1', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('aaabbbccc'),
|
||||
new TextOperation()
|
||||
.retain(3)
|
||||
.insert('xxx', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(6),
|
||||
new TextOperation()
|
||||
.retain(3)
|
||||
.insert('yyy', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user2',
|
||||
}),
|
||||
})
|
||||
.retain(6)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'aaaxxxyyybbbccc',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 3, length: 3 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 6, length: 3 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a comment even if it is completely removed in one operation', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo bar baz', [
|
||||
{
|
||||
id: 'comment1',
|
||||
ranges: [{ pos: 4, length: 3 }],
|
||||
resolved: false,
|
||||
},
|
||||
]),
|
||||
new TextOperation().retain(4).remove(4).retain(3),
|
||||
new TextOperation()
|
||||
.retain(7)
|
||||
.insert('qux ', {
|
||||
commentIds: ['comment1'],
|
||||
})
|
||||
.retain(4)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo qux baz',
|
||||
comments: [{ id: 'comment1', ranges: [{ pos: 4, length: 4 }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('extends a comment to both ranges if both operations add text in it', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo bar baz', [
|
||||
{
|
||||
id: 'comment1',
|
||||
ranges: [{ pos: 4, length: 3 }],
|
||||
resolved: false,
|
||||
},
|
||||
]),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('qux ', {
|
||||
commentIds: ['comment1'],
|
||||
})
|
||||
.retain(7),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('corge ', { commentIds: ['comment1'] })
|
||||
.retain(7)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo qux corge bar baz',
|
||||
comments: [{ id: 'comment1', ranges: [{ pos: 4, length: 13 }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a tracked change from both operations at different places', function () {
|
||||
expect(
|
||||
transform(
|
||||
new StringFileData('foo bar baz'),
|
||||
new TextOperation()
|
||||
.retain(4)
|
||||
.insert('qux ', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
}),
|
||||
})
|
||||
.retain(7),
|
||||
new TextOperation()
|
||||
.retain(8)
|
||||
.insert('corge ', {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user2',
|
||||
}),
|
||||
})
|
||||
.retain(3)
|
||||
)
|
||||
).to.deep.equal({
|
||||
content: 'foo qux bar corge baz',
|
||||
trackedChanges: [
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user1',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 12, length: 6 },
|
||||
tracking: {
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
type: 'insert',
|
||||
userId: 'user2',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function expectInverseToLeadToInitialState(fileData, operation) {
|
||||
const initialState = fileData
|
||||
const result = initialState.toRaw()
|
||||
const invertedOperation = operation.invert(initialState)
|
||||
operation.apply(initialState)
|
||||
invertedOperation.apply(initialState)
|
||||
const invertedResult = initialState.toRaw()
|
||||
expect(invertedResult).to.deep.equal(result)
|
||||
}
|
||||
|
||||
function compose(fileData, op1, op2) {
|
||||
const copy = StringFileData.fromRaw(fileData.toRaw())
|
||||
op1.apply(fileData)
|
||||
op2.apply(fileData)
|
||||
const result1 = fileData.toRaw()
|
||||
|
||||
const composed = op1.compose(op2)
|
||||
composed.apply(copy)
|
||||
const result2 = copy.toRaw()
|
||||
|
||||
expect(result1).to.deep.equal(result2)
|
||||
return fileData.toRaw()
|
||||
}
|
||||
|
||||
function transform(fileData, a, b) {
|
||||
const initialState = fileData
|
||||
const aFileData = StringFileData.fromRaw(initialState.toRaw())
|
||||
const bFileData = StringFileData.fromRaw(initialState.toRaw())
|
||||
|
||||
const [aPrime, bPrime] = TextOperation.transform(a, b)
|
||||
a.apply(aFileData)
|
||||
bPrime.apply(aFileData)
|
||||
b.apply(bFileData)
|
||||
aPrime.apply(bFileData)
|
||||
|
||||
const resultA = aFileData.toRaw()
|
||||
const resultB = bFileData.toRaw()
|
||||
expect(resultA).to.deep.equal(resultB)
|
||||
|
||||
return aFileData.toRaw()
|
||||
}
|
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/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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
869
libraries/overleaf-editor-core/test/tracked_change_list.test.js
Normal file
869
libraries/overleaf-editor-core/test/tracked_change_list.test.js
Normal file
@@ -0,0 +1,869 @@
|
||||
// @ts-check
|
||||
const TrackedChangeList = require('../lib/file_data/tracked_change_list')
|
||||
const TrackingProps = require('../lib/file_data/tracking_props')
|
||||
const ClearTrackingProps = require('../lib/file_data/clear_tracking_props')
|
||||
const { expect } = require('chai')
|
||||
/** @import { TrackedChangeRawData } from '../lib/types' */
|
||||
|
||||
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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: new ClearTrackingProps(),
|
||||
})
|
||||
expect(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: new ClearTrackingProps(),
|
||||
})
|
||||
expect(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: new ClearTrackingProps(),
|
||||
})
|
||||
expect(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: new ClearTrackingProps(),
|
||||
})
|
||||
expect(trackedChanges.length).to.equal(0)
|
||||
expect(trackedChanges.toRaw()).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should append a new tracked change when retaining a range from another user with tracking info', function () {
|
||||
const trackedChanges = TrackedChangeList.fromRaw([
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
trackedChanges.applyRetain(8, 1, {
|
||||
tracking: TrackingProps.fromRaw({
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
}),
|
||||
})
|
||||
expect(trackedChanges.length).to.equal(2)
|
||||
expect(trackedChanges.toRaw()).to.deep.equal([
|
||||
{
|
||||
range: { pos: 4, length: 4 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
userId: 'user1',
|
||||
ts: '2023-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: { pos: 8, length: 1 },
|
||||
tracking: {
|
||||
type: 'delete',
|
||||
userId: 'user2',
|
||||
ts: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user