text_operation.test.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. // @ts-check
  2. //
  3. // These tests are based on the OT.js tests:
  4. // https://github.com/Operational-Transformation/ot.js/blob/
  5. // 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js
  6. //
  7. 'use strict'
  8. const { expect } = require('chai')
  9. const random = require('./support/random')
  10. const randomOperation = require('./support/random_text_operation')
  11. const ot = require('..')
  12. const TextOperation = ot.TextOperation
  13. const StringFileData = require('../lib/file_data/string_file_data')
  14. const { RetainOp, InsertOp, RemoveOp } = require('../lib/operation/scan_op')
  15. describe('TextOperation', function () {
  16. const numTrials = 500
  17. it('tracks base and target lengths', function () {
  18. const o = new TextOperation()
  19. expect(o.baseLength).to.equal(0)
  20. expect(o.targetLength).to.equal(0)
  21. o.retain(5)
  22. expect(o.baseLength).to.equal(5)
  23. expect(o.targetLength).to.equal(5)
  24. o.insert('abc')
  25. expect(o.baseLength).to.equal(5)
  26. expect(o.targetLength).to.equal(8)
  27. o.retain(2)
  28. expect(o.baseLength).to.equal(7)
  29. expect(o.targetLength).to.equal(10)
  30. o.remove(2)
  31. expect(o.baseLength).to.equal(9)
  32. expect(o.targetLength).to.equal(10)
  33. })
  34. it('supports chaining', function () {
  35. const o = new TextOperation()
  36. .retain(5)
  37. .retain(0)
  38. .insert('lorem')
  39. .insert('')
  40. .remove('abc')
  41. .remove(3)
  42. .remove(0)
  43. .remove('')
  44. expect(o.ops.length).to.equal(3)
  45. })
  46. it('ignores empty operations', function () {
  47. const o = new TextOperation()
  48. o.retain(0)
  49. o.insert('')
  50. o.remove('')
  51. expect(o.ops.length).to.equal(0)
  52. })
  53. it('checks for equality', function () {
  54. const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
  55. const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
  56. expect(op1.equals(op2)).to.be.true
  57. op1.remove(1)
  58. op2.retain(1)
  59. expect(op1.equals(op2)).to.be.false
  60. })
  61. it('merges ops', function () {
  62. function last(arr) {
  63. return arr[arr.length - 1]
  64. }
  65. const o = new TextOperation()
  66. expect(o.ops.length).to.equal(0)
  67. o.retain(2)
  68. expect(o.ops.length).to.equal(1)
  69. expect(last(o.ops).equals(new RetainOp(2))).to.be.true
  70. o.retain(3)
  71. expect(o.ops.length).to.equal(1)
  72. expect(last(o.ops).equals(new RetainOp(5))).to.be.true
  73. o.insert('abc')
  74. expect(o.ops.length).to.equal(2)
  75. expect(last(o.ops).equals(new InsertOp('abc'))).to.be.true
  76. o.insert('xyz')
  77. expect(o.ops.length).to.equal(2)
  78. expect(last(o.ops).equals(new InsertOp('abcxyz'))).to.be.true
  79. o.remove('d')
  80. expect(o.ops.length).to.equal(3)
  81. expect(last(o.ops).equals(new RemoveOp(1))).to.be.true
  82. o.remove('d')
  83. expect(o.ops.length).to.equal(3)
  84. expect(last(o.ops).equals(new RemoveOp(2))).to.be.true
  85. })
  86. it('checks for no-ops', function () {
  87. const o = new TextOperation()
  88. expect(o.isNoop()).to.be.true
  89. o.retain(5)
  90. expect(o.isNoop()).to.be.true
  91. o.retain(3)
  92. expect(o.isNoop()).to.be.true
  93. o.insert('lorem')
  94. expect(o.isNoop()).to.be.false
  95. })
  96. it('converts to string', function () {
  97. const o = new TextOperation()
  98. o.retain(2)
  99. o.insert('lorem')
  100. o.remove('ipsum')
  101. o.retain(5)
  102. expect(o.toString()).to.equal(
  103. "retain 2, insert 'lorem', remove 5, retain 5"
  104. )
  105. })
  106. it('converts from JSON', function () {
  107. const ops = [2, -1, -1, 'cde']
  108. const o = TextOperation.fromJSON({ textOperation: ops })
  109. expect(o.ops.length).to.equal(3)
  110. expect(o.baseLength).to.equal(4)
  111. expect(o.targetLength).to.equal(5)
  112. function assertIncorrectAfter(fn) {
  113. const ops2 = ops.slice(0)
  114. fn(ops2)
  115. expect(() => {
  116. TextOperation.fromJSON({ textOperation: ops2 })
  117. }).to.throw
  118. }
  119. assertIncorrectAfter(ops2 => {
  120. ops2.push({ insert: 'x' })
  121. })
  122. assertIncorrectAfter(ops2 => {
  123. ops2.push(null)
  124. })
  125. })
  126. it(
  127. 'applies (randomised)',
  128. random.test(numTrials, () => {
  129. const str = random.string(50)
  130. const o = randomOperation(str)
  131. expect(str.length).to.equal(o.baseLength)
  132. const file = new StringFileData(str)
  133. o.apply(file)
  134. const result = file.getContent()
  135. expect(result.length).to.equal(o.targetLength)
  136. })
  137. )
  138. it(
  139. 'inverts (randomised)',
  140. random.test(numTrials, () => {
  141. const str = random.string(50)
  142. const o = randomOperation(str)
  143. const p = o.invert(new StringFileData(str))
  144. expect(o.baseLength).to.equal(p.targetLength)
  145. expect(o.targetLength).to.equal(p.baseLength)
  146. const file = new StringFileData(str)
  147. o.apply(file)
  148. p.apply(file)
  149. const result = file.getContent()
  150. expect(result).to.equal(str)
  151. })
  152. )
  153. it(
  154. 'converts to/from JSON (randomised)',
  155. random.test(numTrials, () => {
  156. const doc = random.string(50)
  157. const operation = randomOperation(doc)
  158. const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
  159. expect(operation.equals(roundTripOperation)).to.be.true
  160. })
  161. )
  162. it(
  163. 'composes (randomised)',
  164. random.test(numTrials, () => {
  165. // invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
  166. const str = random.string(20)
  167. const a = randomOperation(str)
  168. const file = new StringFileData(str)
  169. a.apply(file)
  170. const afterA = file.getContent()
  171. expect(afterA.length).to.equal(a.targetLength)
  172. const b = randomOperation(afterA)
  173. b.apply(file)
  174. const afterB = file.getContent()
  175. expect(afterB.length).to.equal(b.targetLength)
  176. const ab = a.compose(b)
  177. expect(ab.targetLength).to.equal(b.targetLength)
  178. ab.apply(new StringFileData(str))
  179. const afterAB = file.getContent()
  180. expect(afterAB).to.equal(afterB)
  181. })
  182. )
  183. it(
  184. 'transforms (randomised)',
  185. random.test(numTrials, () => {
  186. // invariant: compose(a, b') = compose(b, a')
  187. // where (a', b') = transform(a, b)
  188. const str = random.string(20)
  189. const a = randomOperation(str)
  190. const b = randomOperation(str)
  191. const primes = TextOperation.transform(a, b)
  192. const aPrime = primes[0]
  193. const bPrime = primes[1]
  194. const abPrime = a.compose(bPrime)
  195. const baPrime = b.compose(aPrime)
  196. const abFile = new StringFileData(str)
  197. const baFile = new StringFileData(str)
  198. abPrime.apply(abFile)
  199. baPrime.apply(baFile)
  200. expect(abPrime.equals(baPrime)).to.be.true
  201. expect(abFile.getContent()).to.equal(baFile.getContent())
  202. })
  203. )
  204. it('throws when invalid operations are applied', function () {
  205. const operation = new TextOperation().retain(1)
  206. expect(() => {
  207. operation.apply(new StringFileData(''))
  208. }).to.throw(TextOperation.ApplyError)
  209. expect(() => {
  210. operation.apply(new StringFileData(' '))
  211. }).not.to.throw
  212. })
  213. it('throws when insert text contains non BMP chars', function () {
  214. const operation = new TextOperation()
  215. const str = '𝌆\n'
  216. expect(() => {
  217. operation.insert(str)
  218. }).to.throw(
  219. TextOperation.UnprocessableError,
  220. /inserted text contains non BMP characters/
  221. )
  222. })
  223. it('throws when base string contains non BMP chars', function () {
  224. const operation = new TextOperation()
  225. const str = '𝌆\n'
  226. expect(() => {
  227. operation.apply(new StringFileData(str))
  228. }).to.throw(
  229. TextOperation.UnprocessableError,
  230. /string contains non BMP characters/
  231. )
  232. })
  233. it('throws at from JSON when it contains non BMP chars', function () {
  234. const operation = ['𝌆\n']
  235. expect(() => {
  236. TextOperation.fromJSON({ textOperation: operation })
  237. }).to.throw(
  238. TextOperation.UnprocessableError,
  239. /inserted text contains non BMP characters/
  240. )
  241. })
  242. })