mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
[overleaf-editor-core] Introduce ScanOp and subclasses (#16695)
* [overleaf-editor-core] Introduce ScanOp and subclasses * [overleaf-editor-core] Remove unused methods * [overleaf-editor-core] Add tests for ScanOps * [overleaf-editor-core] Simplify merge * [overleaf-editor-core] Make ApplyContexts mutable * [overleaf-editor-core] Remove unnecessary containsNonBmpChars check * [overleaf-editor-core] Revert to using reduce * [overleaf-editor-core] Modify inputCursor after using it * [overleaf-editor-core] Rename DeleteOp to RemoveOp * [overleaf-editor-core] Remove useless constructor * [overleaf-editor-core] Mutate in mergeWith * [overleaf-editor-core] Add check for out-of-bounds retain * [overleaf-editor-core] Move error import GitOrigin-RevId: d07bd58177579638551257d56f087e8967e5b52f
This commit is contained in:
parent
b87dad77d9
commit
63ff16843c
7 changed files with 880 additions and 241 deletions
37
libraries/overleaf-editor-core/lib/errors.js
Normal file
37
libraries/overleaf-editor-core/lib/errors.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
const OError = require('@overleaf/o-error')
|
||||
|
||||
class UnprocessableError extends OError {}
|
||||
|
||||
class ApplyError extends UnprocessableError {
|
||||
constructor(message, operation, operand) {
|
||||
super(message, { operation, operand })
|
||||
this.operation = operation
|
||||
this.operand = operand
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidInsertionError extends UnprocessableError {
|
||||
constructor(str, operation) {
|
||||
super('inserted text contains non BMP characters', { str, operation })
|
||||
this.str = str
|
||||
this.operation = operation
|
||||
}
|
||||
}
|
||||
|
||||
class TooLongError extends UnprocessableError {
|
||||
constructor(operation, resultLength) {
|
||||
super(`resulting string would be too long: ${resultLength}`, {
|
||||
operation,
|
||||
resultLength,
|
||||
})
|
||||
this.operation = operation
|
||||
this.resultLength = resultLength
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnprocessableError,
|
||||
ApplyError,
|
||||
InvalidInsertionError,
|
||||
TooLongError,
|
||||
}
|
364
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
364
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
|
@ -0,0 +1,364 @@
|
|||
// @ts-check
|
||||
const { containsNonBmpChars } = require('../util')
|
||||
const {
|
||||
ApplyError,
|
||||
InvalidInsertionError,
|
||||
UnprocessableError,
|
||||
} = require('../errors')
|
||||
|
||||
/** @typedef {{ result: string, inputCursor: number}} ApplyContext */
|
||||
/** @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext */
|
||||
|
||||
class ScanOp {
|
||||
constructor() {
|
||||
if (this.constructor === ScanOp) {
|
||||
throw new Error('Cannot instantiate abstract class')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an operation to a string
|
||||
* @param {string} input
|
||||
* @param {ApplyContext} current
|
||||
* @returns {ApplyContext}
|
||||
*/
|
||||
apply(input, current) {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an operation to a length
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} raw
|
||||
* @returns {ScanOp}
|
||||
*/
|
||||
static fromJSON(raw) {
|
||||
if (isRetain(raw)) {
|
||||
return RetainOp.fromJSON(raw)
|
||||
} else if (isInsert(raw)) {
|
||||
return InsertOp.fromJSON(raw)
|
||||
} else if (isRemove(raw)) {
|
||||
return RemoveOp.fromJSON(raw)
|
||||
}
|
||||
throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether two ScanOps are equal
|
||||
* @param {ScanOp} _other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
equals(_other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether two ScanOps can be merged into a single operation
|
||||
* @param {ScanOp} other
|
||||
* @returns
|
||||
*/
|
||||
canMergeWith(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two ScanOps into a single operation
|
||||
* @param {ScanOp} _other
|
||||
* @returns {void}
|
||||
*/
|
||||
mergeWith(_other) {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
toString() {
|
||||
'ScanOp'
|
||||
}
|
||||
}
|
||||
|
||||
class InsertOp extends ScanOp {
|
||||
constructor(insertion) {
|
||||
super()
|
||||
if (typeof insertion !== 'string') {
|
||||
throw new InvalidInsertionError('insertion must be a string')
|
||||
}
|
||||
if (containsNonBmpChars(insertion)) {
|
||||
throw new InvalidInsertionError('insertion contains non-BMP characters')
|
||||
}
|
||||
this.insertion = insertion
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{i: string} | string} op
|
||||
* @returns {InsertOp}
|
||||
*/
|
||||
static fromJSON(op) {
|
||||
if (typeof op === 'string') {
|
||||
return new InsertOp(op)
|
||||
}
|
||||
// It must be an object with an 'i' property.
|
||||
if (typeof op.i !== 'string') {
|
||||
throw new InvalidInsertionError(
|
||||
'insert operation must have a string property'
|
||||
)
|
||||
}
|
||||
return new InsertOp(op.i)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {string} input
|
||||
* @param {ApplyContext} current
|
||||
* @returns {ApplyContext}
|
||||
* */
|
||||
apply(input, current) {
|
||||
if (containsNonBmpChars(this.insertion)) {
|
||||
throw new InvalidInsertionError(input, this.toJSON())
|
||||
}
|
||||
current.result += this.insertion
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
current.length += this.insertion.length
|
||||
return current
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
equals(other) {
|
||||
if (!(other instanceof InsertOp)) {
|
||||
return false
|
||||
}
|
||||
return this.insertion === other.insertion
|
||||
}
|
||||
|
||||
canMergeWith(other) {
|
||||
return other instanceof InsertOp
|
||||
}
|
||||
|
||||
mergeWith(other) {
|
||||
if (!(other instanceof InsertOp)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.insertion += other.insertion
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// TODO: Once we add metadata to the operation, generate an object rather
|
||||
// than the compact representation.
|
||||
return this.insertion
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `insert '${this.insertion}'`
|
||||
}
|
||||
}
|
||||
|
||||
class RetainOp extends ScanOp {
|
||||
constructor(length) {
|
||||
super()
|
||||
if (length < 0) {
|
||||
throw new Error('length must be non-negative')
|
||||
}
|
||||
this.length = length
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {string} input
|
||||
* @param {ApplyContext} current
|
||||
* @returns {ApplyContext}
|
||||
* */
|
||||
apply(input, current) {
|
||||
if (current.inputCursor + this.length > input.length) {
|
||||
throw new ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
this.toJSON(),
|
||||
input
|
||||
)
|
||||
}
|
||||
current.result += input.slice(
|
||||
current.inputCursor,
|
||||
current.inputCursor + this.length
|
||||
)
|
||||
current.inputCursor += this.length
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
if (current.inputCursor + this.length > current.inputLength) {
|
||||
throw new ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
this.toJSON(),
|
||||
current.inputLength
|
||||
)
|
||||
}
|
||||
current.length += this.length
|
||||
current.inputCursor += this.length
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number | {r: number}} op
|
||||
* @returns
|
||||
*/
|
||||
static fromJSON(op) {
|
||||
if (typeof op === 'number') {
|
||||
return new RetainOp(op)
|
||||
}
|
||||
// It must be an object with a 'r' property.
|
||||
if (typeof op.r !== 'number') {
|
||||
throw new Error('retain operation must have a number property')
|
||||
}
|
||||
return new RetainOp(op.r)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
equals(other) {
|
||||
if (!(other instanceof RetainOp)) {
|
||||
return false
|
||||
}
|
||||
return this.length === other.length
|
||||
}
|
||||
|
||||
canMergeWith(other) {
|
||||
return other instanceof RetainOp
|
||||
}
|
||||
|
||||
mergeWith(other) {
|
||||
if (!(other instanceof RetainOp)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.length += other.length
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// TODO: Once we add metadata to the operation, generate an object rather
|
||||
// than the compact representation.
|
||||
return this.length
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `retain ${this.length}`
|
||||
}
|
||||
}
|
||||
|
||||
class RemoveOp extends ScanOp {
|
||||
constructor(length) {
|
||||
super()
|
||||
if (length < 0) {
|
||||
throw new Error('length must be non-negative')
|
||||
}
|
||||
this.length = length
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {string} _input
|
||||
* @param {ApplyContext} current
|
||||
* @returns {ApplyContext}
|
||||
*/
|
||||
apply(_input, current) {
|
||||
current.inputCursor += this.length
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
current.inputCursor += this.length
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} op
|
||||
* @returns {RemoveOp}
|
||||
*/
|
||||
static fromJSON(op) {
|
||||
if (typeof op !== 'number' || op > 0) {
|
||||
throw new Error('delete operation must be a negative number')
|
||||
}
|
||||
return new RemoveOp(-op)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
equals(other) {
|
||||
if (!(other instanceof RemoveOp)) {
|
||||
return false
|
||||
}
|
||||
return this.length === other.length
|
||||
}
|
||||
|
||||
canMergeWith(other) {
|
||||
return other instanceof RemoveOp
|
||||
}
|
||||
|
||||
mergeWith(other) {
|
||||
if (!(other instanceof RemoveOp)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.length += other.length
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return -this.length
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `remove ${this.length}`
|
||||
}
|
||||
}
|
||||
|
||||
function isRetain(op) {
|
||||
return (
|
||||
(typeof op === 'number' && op > 0) ||
|
||||
(typeof op === 'object' && typeof op.r === 'number' && op.r > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function isInsert(op) {
|
||||
return (
|
||||
typeof op === 'string' ||
|
||||
(typeof op === 'object' && typeof op.i === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isRemove(op) {
|
||||
return typeof op === 'number' && op < 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ScanOp,
|
||||
InsertOp,
|
||||
RetainOp,
|
||||
RemoveOp,
|
||||
isRetain,
|
||||
isInsert,
|
||||
isRemove,
|
||||
}
|
|
@ -10,39 +10,24 @@
|
|||
|
||||
'use strict'
|
||||
const containsNonBmpChars = require('../util').containsNonBmpChars
|
||||
const OError = require('@overleaf/o-error')
|
||||
const EditOperation = require('./edit_operation')
|
||||
const {
|
||||
ScanOp,
|
||||
RetainOp,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
isRetain,
|
||||
isInsert,
|
||||
isRemove,
|
||||
} = require('./scan_op')
|
||||
const {
|
||||
UnprocessableError,
|
||||
ApplyError,
|
||||
InvalidInsertionError,
|
||||
TooLongError,
|
||||
} = require('../errors')
|
||||
/** @typedef {import('../file_data/string_file_data')} StringFileData */
|
||||
|
||||
class UnprocessableError extends OError {}
|
||||
|
||||
class ApplyError extends UnprocessableError {
|
||||
constructor(message, operation, operand) {
|
||||
super(message, { operation, operand })
|
||||
this.operation = operation
|
||||
this.operand = operand
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidInsertionError extends UnprocessableError {
|
||||
constructor(str, operation) {
|
||||
super('inserted text contains non BMP characters', { str, operation })
|
||||
this.str = str
|
||||
this.operation = operation
|
||||
}
|
||||
}
|
||||
|
||||
class TooLongError extends UnprocessableError {
|
||||
constructor(operation, resultLength) {
|
||||
super(`resulting string would be too long: ${resultLength}`, {
|
||||
operation,
|
||||
resultLength,
|
||||
})
|
||||
this.operation = operation
|
||||
this.resultLength = resultLength
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty text operation.
|
||||
* @extends EditOperation
|
||||
|
@ -58,9 +43,6 @@ class TextOperation extends EditOperation {
|
|||
static ApplyError = ApplyError
|
||||
static InvalidInsertionError = InvalidInsertionError
|
||||
static TooLongError = TooLongError
|
||||
static isRetain = isRetain
|
||||
static isInsert = isInsert
|
||||
static isRemove = isRemove
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
@ -68,6 +50,7 @@ class TextOperation extends EditOperation {
|
|||
// if an imaginary cursor runs over the entire string and skips over some
|
||||
// parts, removes some parts and inserts characters at some positions. These
|
||||
// actions (skip/remove/insert) are stored as an array in the "ops" property.
|
||||
/** @type {ScanOp[]} */
|
||||
this.ops = []
|
||||
// An operation's baseLength is the length of every string the operation
|
||||
// can be applied to.
|
||||
|
@ -88,7 +71,7 @@ class TextOperation extends EditOperation {
|
|||
return false
|
||||
}
|
||||
for (let i = 0; i < this.ops.length; i++) {
|
||||
if (this.ops[i] !== other.ops[i]) {
|
||||
if (!this.ops[i].equals(other.ops[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -101,64 +84,76 @@ class TextOperation extends EditOperation {
|
|||
|
||||
/**
|
||||
* Skip over a given number of characters.
|
||||
* @param {number | {r: number}} n
|
||||
*/
|
||||
retain(n) {
|
||||
if (typeof n !== 'number') {
|
||||
throw new Error('retain expects an integer')
|
||||
}
|
||||
if (n === 0) {
|
||||
return this
|
||||
}
|
||||
this.baseLength += n
|
||||
this.targetLength += n
|
||||
if (isRetain(this.ops[this.ops.length - 1])) {
|
||||
|
||||
if (!isRetain(n)) {
|
||||
throw new Error('retain expects an integer or a retain object')
|
||||
}
|
||||
const newOp = RetainOp.fromJSON(n)
|
||||
|
||||
if (newOp.length === 0) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.baseLength += newOp.length
|
||||
this.targetLength += newOp.length
|
||||
|
||||
const lastOperation = this.ops[this.ops.length - 1]
|
||||
if (lastOperation?.canMergeWith(newOp)) {
|
||||
// The last op is a retain op => we can merge them into one op.
|
||||
this.ops[this.ops.length - 1] += n
|
||||
lastOperation.mergeWith(newOp)
|
||||
} else {
|
||||
// Create a new op.
|
||||
this.ops.push(n)
|
||||
this.ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a string at the current position.
|
||||
* @param {string | {i: string}} insertValue
|
||||
*/
|
||||
insert(str) {
|
||||
if (typeof str !== 'string') {
|
||||
throw new Error('insert expects a string')
|
||||
insert(insertValue) {
|
||||
if (!isInsert(insertValue)) {
|
||||
throw new Error('insert expects a string or an insert object')
|
||||
}
|
||||
if (containsNonBmpChars(str)) {
|
||||
throw new TextOperation.InvalidInsertionError(str)
|
||||
}
|
||||
if (str === '') {
|
||||
const newOp = InsertOp.fromJSON(insertValue)
|
||||
if (newOp.insertion === '') {
|
||||
return this
|
||||
}
|
||||
this.targetLength += str.length
|
||||
this.targetLength += newOp.insertion.length
|
||||
const ops = this.ops
|
||||
if (isInsert(ops[ops.length - 1])) {
|
||||
const lastOp = this.ops[this.ops.length - 1]
|
||||
if (lastOp?.canMergeWith(newOp)) {
|
||||
// Merge insert op.
|
||||
ops[ops.length - 1] += str
|
||||
} else if (isRemove(ops[ops.length - 1])) {
|
||||
lastOp.mergeWith(newOp)
|
||||
} else if (lastOp instanceof RemoveOp) {
|
||||
// It doesn't matter when an operation is applied whether the operation
|
||||
// is remove(3), insert("something") or insert("something"), remove(3).
|
||||
// Here we enforce that in this case, the insert op always comes first.
|
||||
// This makes all operations that have the same effect when applied to
|
||||
// a document of the right length equal in respect to the `equals` method.
|
||||
if (isInsert(ops[ops.length - 2])) {
|
||||
ops[ops.length - 2] += str
|
||||
const secondToLastOp = ops[ops.length - 2]
|
||||
if (secondToLastOp?.canMergeWith(newOp)) {
|
||||
secondToLastOp.mergeWith(newOp)
|
||||
} else {
|
||||
ops[ops.length] = ops[ops.length - 1]
|
||||
ops[ops.length - 2] = str
|
||||
ops[ops.length - 2] = newOp
|
||||
}
|
||||
} else {
|
||||
ops.push(str)
|
||||
ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a string at the current position.
|
||||
* @param {number | string} n
|
||||
*/
|
||||
remove(n) {
|
||||
if (typeof n === 'string') {
|
||||
|
@ -173,11 +168,13 @@ class TextOperation extends EditOperation {
|
|||
if (n > 0) {
|
||||
n = -n
|
||||
}
|
||||
const newOp = RemoveOp.fromJSON(n)
|
||||
this.baseLength -= n
|
||||
if (isRemove(this.ops[this.ops.length - 1])) {
|
||||
this.ops[this.ops.length - 1] += n
|
||||
const lastOp = this.ops[this.ops.length - 1]
|
||||
if (lastOp?.canMergeWith(newOp)) {
|
||||
lastOp.mergeWith(newOp)
|
||||
} else {
|
||||
this.ops.push(n)
|
||||
this.ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -187,7 +184,8 @@ class TextOperation extends EditOperation {
|
|||
*/
|
||||
isNoop() {
|
||||
return (
|
||||
this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0]))
|
||||
this.ops.length === 0 ||
|
||||
(this.ops.length === 1 && this.ops[0] instanceof RetainOp)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -195,24 +193,14 @@ class TextOperation extends EditOperation {
|
|||
* Pretty printing.
|
||||
*/
|
||||
toString() {
|
||||
return this.ops
|
||||
.map(op => {
|
||||
if (isRetain(op)) {
|
||||
return 'retain ' + op
|
||||
} else if (isInsert(op)) {
|
||||
return "insert '" + op + "'"
|
||||
} else {
|
||||
return 'remove ' + -op
|
||||
}
|
||||
})
|
||||
.join(', ')
|
||||
return this.ops.map(op => op.toString()).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toJSON() {
|
||||
return { textOperation: this.ops }
|
||||
return { textOperation: this.ops.map(op => op.toJSON()) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -220,8 +208,7 @@ class TextOperation extends EditOperation {
|
|||
*/
|
||||
static fromJSON = function ({ textOperation: ops }) {
|
||||
const o = new TextOperation()
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
for (const op of ops) {
|
||||
if (isRetain(op)) {
|
||||
o.retain(op)
|
||||
} else if (isInsert(op)) {
|
||||
|
@ -229,12 +216,7 @@ class TextOperation extends EditOperation {
|
|||
} else if (isRemove(op)) {
|
||||
o.remove(op)
|
||||
} else {
|
||||
throw new Error(
|
||||
'unknown operation: ' +
|
||||
JSON.stringify(op) +
|
||||
' in ' +
|
||||
JSON.stringify(ops)
|
||||
)
|
||||
throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
|
||||
}
|
||||
}
|
||||
return o
|
||||
|
@ -265,36 +247,13 @@ class TextOperation extends EditOperation {
|
|||
)
|
||||
}
|
||||
|
||||
// Build up the result string directly by concatenation (which is actually
|
||||
// faster than joining arrays because it is optimised in v8).
|
||||
let result = ''
|
||||
let strIndex = 0
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
if (strIndex + op > str.length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
// Copy skipped part of the old string.
|
||||
result += str.slice(strIndex, strIndex + op)
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
if (containsNonBmpChars(op)) {
|
||||
throw new TextOperation.InvalidInsertionError(str, operation)
|
||||
}
|
||||
// Insert string.
|
||||
result += op
|
||||
} else {
|
||||
// remove op
|
||||
strIndex -= op
|
||||
}
|
||||
}
|
||||
if (strIndex !== str.length) {
|
||||
const { inputCursor, result } = ops.reduce(
|
||||
(intermediate, op) => op.apply(str, intermediate),
|
||||
{ result: '', inputCursor: 0 }
|
||||
)
|
||||
|
||||
if (inputCursor !== str.length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation didn't operate on the whole string.",
|
||||
operation,
|
||||
|
@ -321,31 +280,13 @@ class TextOperation extends EditOperation {
|
|||
length
|
||||
)
|
||||
}
|
||||
let newLength = 0
|
||||
let strIndex = 0
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
if (strIndex + op > length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
operation,
|
||||
length
|
||||
)
|
||||
}
|
||||
// Copy skipped part of the old string.
|
||||
newLength += op
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
// Insert string.
|
||||
newLength += op.length
|
||||
} else {
|
||||
// remove op
|
||||
strIndex -= op
|
||||
}
|
||||
}
|
||||
if (strIndex !== length) {
|
||||
|
||||
const { length: newLength, inputCursor } = this.ops.reduce(
|
||||
(intermediate, op) => op.applyToLength(intermediate),
|
||||
{ length: 0, inputCursor: 0, inputLength: length }
|
||||
)
|
||||
|
||||
if (inputCursor !== length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation didn't operate on the whole string.",
|
||||
operation,
|
||||
|
@ -369,15 +310,17 @@ class TextOperation extends EditOperation {
|
|||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
inverse.retain(op)
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
inverse.remove(op.length)
|
||||
} else {
|
||||
if (op instanceof RetainOp) {
|
||||
inverse.retain(op.length)
|
||||
strIndex += op.length
|
||||
} else if (op instanceof InsertOp) {
|
||||
inverse.remove(op.insertion.length)
|
||||
} else if (op instanceof RemoveOp) {
|
||||
// remove op
|
||||
inverse.insert(str.slice(strIndex, strIndex - op))
|
||||
strIndex -= op
|
||||
inverse.insert(str.slice(strIndex, strIndex + op.length))
|
||||
strIndex += op.length
|
||||
} else {
|
||||
throw new UnprocessableError('unknown scanop during inversion')
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
|
@ -404,14 +347,14 @@ class TextOperation extends EditOperation {
|
|||
return false
|
||||
}
|
||||
|
||||
if (isInsert(simpleA) && isInsert(simpleB)) {
|
||||
return startA + simpleA.length === startB
|
||||
if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
|
||||
return startA + simpleA.insertion.length === startB
|
||||
}
|
||||
|
||||
if (isRemove(simpleA) && isRemove(simpleB)) {
|
||||
if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
|
||||
// there are two possibilities to delete: with backspace and with the
|
||||
// delete key.
|
||||
return startB - simpleB === startA || startA === startB
|
||||
return startB + simpleB.length === startA || startA === startB
|
||||
}
|
||||
|
||||
return false
|
||||
|
@ -460,13 +403,14 @@ class TextOperation extends EditOperation {
|
|||
break
|
||||
}
|
||||
|
||||
if (isRemove(op1)) {
|
||||
operation.remove(op1)
|
||||
if (op1 instanceof RemoveOp) {
|
||||
operation.remove(-op1.length)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
if (isInsert(op2)) {
|
||||
operation.insert(op2)
|
||||
|
||||
if (op2 instanceof InsertOp) {
|
||||
operation.insert(op2.insertion)
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
@ -482,57 +426,57 @@ class TextOperation extends EditOperation {
|
|||
)
|
||||
}
|
||||
|
||||
if (isRetain(op1) && isRetain(op2)) {
|
||||
if (op1 > op2) {
|
||||
operation.retain(op2)
|
||||
op1 = op1 - op2
|
||||
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||
if (op1.length > op2.length) {
|
||||
operation.retain(op2.length)
|
||||
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
operation.retain(op1)
|
||||
} else if (op1.length === op2.length) {
|
||||
operation.retain(op1.length)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.retain(op1)
|
||||
op2 = op2 - op1
|
||||
operation.retain(op1.length)
|
||||
op2 = ScanOp.fromJSON(op2.length - op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isInsert(op1) && isRemove(op2)) {
|
||||
if (op1.length > -op2) {
|
||||
op1 = op1.slice(-op2)
|
||||
} else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
|
||||
if (op1.insertion.length > op2.length) {
|
||||
op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === -op2) {
|
||||
} else if (op1.insertion.length === op2.length) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = op2 + op1.length
|
||||
op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isInsert(op1) && isRetain(op2)) {
|
||||
if (op1.length > op2) {
|
||||
operation.insert(op1.slice(0, op2))
|
||||
op1 = op1.slice(op2)
|
||||
} else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
|
||||
if (op1.insertion.length > op2.length) {
|
||||
operation.insert(op1.insertion.slice(0, op2.length))
|
||||
op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2) {
|
||||
operation.insert(op1)
|
||||
} else if (op1.insertion.length === op2.length) {
|
||||
operation.insert(op1.insertion)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.insert(op1)
|
||||
op2 = op2 - op1.length
|
||||
operation.insert(op1.insertion)
|
||||
op2 = ScanOp.fromJSON(op2.length - op1.insertion.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isRetain(op1) && isRemove(op2)) {
|
||||
if (op1 > -op2) {
|
||||
operation.remove(op2)
|
||||
op1 = op1 + op2
|
||||
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||
if (op1.length > op2.length) {
|
||||
operation.remove(-op2.length)
|
||||
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === -op2) {
|
||||
operation.remove(op2)
|
||||
} else if (op1.length === op2.length) {
|
||||
operation.remove(-op2.length)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.remove(op1)
|
||||
op2 = op2 + op1
|
||||
operation.remove(op1.length)
|
||||
op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else {
|
||||
|
@ -581,15 +525,15 @@ class TextOperation extends EditOperation {
|
|||
// next two cases: one or both ops are insert ops
|
||||
// => insert the string in the corresponding prime operation, skip it in
|
||||
// the other one. If both op1 and op2 are insert ops, prefer op1.
|
||||
if (isInsert(op1)) {
|
||||
operation1prime.insert(op1)
|
||||
operation2prime.retain(op1.length)
|
||||
if (op1 instanceof InsertOp) {
|
||||
operation1prime.insert(op1.insertion)
|
||||
operation2prime.retain(op1.insertion.length)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
if (isInsert(op2)) {
|
||||
operation1prime.retain(op2.length)
|
||||
operation2prime.insert(op2)
|
||||
if (op2 instanceof InsertOp) {
|
||||
operation1prime.retain(op2.insertion.length)
|
||||
operation2prime.insert(op2.insertion)
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
@ -606,65 +550,65 @@ class TextOperation extends EditOperation {
|
|||
}
|
||||
|
||||
let minl
|
||||
if (isRetain(op1) && isRetain(op2)) {
|
||||
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||
// Simple case: retain/retain
|
||||
if (op1 > op2) {
|
||||
minl = op2
|
||||
op1 = op1 - op2
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
minl = op2
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1
|
||||
op2 = op2 - op1
|
||||
minl = op1.length
|
||||
op2 = ScanOp.fromJSON(op2.length - op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.retain(minl)
|
||||
operation2prime.retain(minl)
|
||||
} else if (isRemove(op1) && isRemove(op2)) {
|
||||
} else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
|
||||
// Both operations remove the same string at the same position. We don't
|
||||
// need to produce any operations, we just skip over the remove ops and
|
||||
// handle the case that one operation removes more than the other.
|
||||
if (-op1 > -op2) {
|
||||
op1 = op1 - op2
|
||||
if (op1.length > op2.length) {
|
||||
op1 = ScanOp.fromJSON(-op1.length - -op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
} else if (op1.length === op2.length) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = op2 - op1
|
||||
op2 = ScanOp.fromJSON(-op2.length - -op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
// next two cases: remove/retain and retain/remove
|
||||
} else if (isRemove(op1) && isRetain(op2)) {
|
||||
if (-op1 > op2) {
|
||||
minl = op2
|
||||
op1 = op1 + op2
|
||||
} else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ScanOp.fromJSON(-op1.length + op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (-op1 === op2) {
|
||||
minl = op2
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = -op1
|
||||
op2 = op2 + op1
|
||||
minl = op1.length
|
||||
op2 = ScanOp.fromJSON(op2.length + -op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.remove(minl)
|
||||
} else if (isRetain(op1) && isRemove(op2)) {
|
||||
if (op1 > -op2) {
|
||||
minl = -op2
|
||||
op1 = op1 + op2
|
||||
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ScanOp.fromJSON(op1.length + -op2.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === -op2) {
|
||||
minl = op1
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op1.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1
|
||||
op2 = op2 + op1
|
||||
minl = op1.length
|
||||
op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation2prime.remove(minl)
|
||||
|
@ -685,27 +629,24 @@ class TextOperation extends EditOperation {
|
|||
// Represented by strings.
|
||||
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
||||
|
||||
function isRetain(op) {
|
||||
return typeof op === 'number' && op > 0
|
||||
}
|
||||
|
||||
function isInsert(op) {
|
||||
return typeof op === 'string'
|
||||
}
|
||||
|
||||
function isRemove(op) {
|
||||
return typeof op === 'number' && op < 0
|
||||
}
|
||||
|
||||
function getSimpleOp(operation, fn) {
|
||||
/**
|
||||
*
|
||||
* @param {TextOperation} operation
|
||||
* @returns {ScanOp | null}
|
||||
*/
|
||||
function getSimpleOp(operation) {
|
||||
const ops = operation.ops
|
||||
switch (ops.length) {
|
||||
case 1:
|
||||
return ops[0]
|
||||
case 2:
|
||||
return isRetain(ops[0]) ? ops[1] : isRetain(ops[1]) ? ops[0] : null
|
||||
return ops[0] instanceof RetainOp
|
||||
? ops[1]
|
||||
: ops[1] instanceof RetainOp
|
||||
? ops[0]
|
||||
: null
|
||||
case 3:
|
||||
if (isRetain(ops[0]) && isRetain(ops[2])) {
|
||||
if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
|
||||
return ops[1]
|
||||
}
|
||||
}
|
||||
|
@ -713,8 +654,8 @@ function getSimpleOp(operation, fn) {
|
|||
}
|
||||
|
||||
function getStartIndex(operation) {
|
||||
if (isRetain(operation.ops[0])) {
|
||||
return operation.ops[0]
|
||||
if (operation.ops[0] instanceof RetainOp) {
|
||||
return operation.ops[0].length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
|
||||
'use strict'
|
||||
|
||||
/*
|
||||
* return true/false if the given string contains non-BMP chars
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {boolean} true if the given string contains non-BMP chars otherwise false
|
||||
*/
|
||||
exports.containsNonBmpChars = function utilContainsNonBmpChars(str) {
|
||||
// check for first (high) surrogate in a non-BMP character
|
||||
|
|
|
@ -31,7 +31,7 @@ describe('EditOperationBuilder', function () {
|
|||
}
|
||||
const op = EditOperationBuilder.fromJSON(raw)
|
||||
expect(op).to.be.an.instanceof(TextOperation)
|
||||
expect(op.ops).to.deep.equal([1, 'foo', 3])
|
||||
expect(op.toJSON()).to.deep.equal(raw)
|
||||
})
|
||||
|
||||
it('Throws error for unsupported operation', function () {
|
||||
|
|
295
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
295
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
|
@ -0,0 +1,295 @@
|
|||
// @ts-check
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
RetainOp,
|
||||
ScanOp,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
} = require('../lib/operation/scan_op')
|
||||
const { UnprocessableError, ApplyError } = require('../lib/errors')
|
||||
|
||||
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({})).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 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 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)
|
||||
})
|
||||
|
||||
it('adds from the input to the result when applied', function () {
|
||||
const op = new RetainOp(3)
|
||||
const { result, inputCursor } = op.apply('abcdefghi', {
|
||||
result: 'xyz',
|
||||
inputCursor: 3,
|
||||
})
|
||||
expect(result).to.equal('xyzdef')
|
||||
expect(inputCursor).to.equal(6)
|
||||
})
|
||||
})
|
||||
|
||||
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 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 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('adds from the insertion to the result when applied', function () {
|
||||
const op = new InsertOp('ghi')
|
||||
const { result, inputCursor } = op.apply('abcdef', {
|
||||
result: 'xyz',
|
||||
inputCursor: 3,
|
||||
})
|
||||
expect(result).to.equal('xyzghi')
|
||||
expect(inputCursor).to.equal(3)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('does not change the result and adds to the cursor when applied', function () {
|
||||
const op = new RemoveOp(3)
|
||||
const { result, inputCursor } = op.apply('abcdefghi', {
|
||||
result: 'xyz',
|
||||
inputCursor: 3,
|
||||
})
|
||||
expect(result).to.equal('xyz')
|
||||
expect(inputCursor).to.equal(6)
|
||||
})
|
||||
})
|
|
@ -13,6 +13,7 @@ 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')
|
||||
|
||||
describe('TextOperation', function () {
|
||||
const numTrials = 500
|
||||
|
@ -73,22 +74,22 @@ describe('TextOperation', function () {
|
|||
expect(o.ops.length).to.equal(0)
|
||||
o.retain(2)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops)).to.equal(2)
|
||||
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)).to.equal(5)
|
||||
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)).to.equal('abc')
|
||||
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)).to.equal('abcxyz')
|
||||
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)).to.equal(-1)
|
||||
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)).to.equal(-2)
|
||||
expect(last(o.ops).equals(new RemoveOp(2))).to.be.true
|
||||
})
|
||||
|
||||
it('checks for no-ops', function () {
|
||||
|
|
Loading…
Reference in a new issue