text_operation.test.js 7.2 KB

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