first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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)
})
})

View 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)
})
})
})

View 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)])
})
})

View 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 }],
},
])
})
})
})

View File

@@ -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()
})
})

View File

@@ -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']))
})
})
})

View 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
})
})

View 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([])
})
})

View 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)
}
})
})

View 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([])
})
})
})

View 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,
])
})
})
})

View File

@@ -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)
})
})

View 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
})
})

View File

@@ -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)
})
})

View File

@@ -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 },
})
})
})

File diff suppressed because it is too large Load Diff

View 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()
})
})
})

View 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')
})
})

View 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)
})
})

View 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('')
})
})
})

View 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'
)
})
})

View File

@@ -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

View 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

View File

@@ -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

View 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()
}

View 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',
},
})
})
})

View 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',
},
},
])
})
})
})