text_operation.test.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955
  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. const TrackingProps = require('../../lib/file_data/tracking_props')
  16. const ClearTrackingProps = require('../../lib/file_data/clear_tracking_props')
  17. describe('TextOperation', function () {
  18. const numTrials = 500
  19. it('tracks base and target lengths', function () {
  20. const o = new TextOperation()
  21. expect(o.baseLength).to.equal(0)
  22. expect(o.targetLength).to.equal(0)
  23. o.retain(5)
  24. expect(o.baseLength).to.equal(5)
  25. expect(o.targetLength).to.equal(5)
  26. o.insert('abc')
  27. expect(o.baseLength).to.equal(5)
  28. expect(o.targetLength).to.equal(8)
  29. o.retain(2)
  30. expect(o.baseLength).to.equal(7)
  31. expect(o.targetLength).to.equal(10)
  32. o.remove(2)
  33. expect(o.baseLength).to.equal(9)
  34. expect(o.targetLength).to.equal(10)
  35. })
  36. it('supports chaining', function () {
  37. const o = new TextOperation()
  38. .retain(5)
  39. .retain(0)
  40. .insert('lorem')
  41. .insert('')
  42. .remove('abc')
  43. .remove(3)
  44. .remove(0)
  45. .remove('')
  46. expect(o.ops.length).to.equal(3)
  47. })
  48. it('ignores empty operations', function () {
  49. const o = new TextOperation()
  50. o.retain(0)
  51. o.insert('')
  52. o.remove('')
  53. expect(o.ops.length).to.equal(0)
  54. })
  55. it('checks for equality', function () {
  56. const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
  57. const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
  58. expect(op1.equals(op2)).to.be.true
  59. op1.remove(1)
  60. op2.retain(1)
  61. expect(op1.equals(op2)).to.be.false
  62. })
  63. it('merges ops', function () {
  64. function last(arr) {
  65. return arr[arr.length - 1]
  66. }
  67. const o = new TextOperation()
  68. expect(o.ops.length).to.equal(0)
  69. o.retain(2)
  70. expect(o.ops.length).to.equal(1)
  71. expect(last(o.ops).equals(new RetainOp(2))).to.be.true
  72. o.retain(3)
  73. expect(o.ops.length).to.equal(1)
  74. expect(last(o.ops).equals(new RetainOp(5))).to.be.true
  75. o.insert('abc')
  76. expect(o.ops.length).to.equal(2)
  77. expect(last(o.ops).equals(new InsertOp('abc'))).to.be.true
  78. o.insert('xyz')
  79. expect(o.ops.length).to.equal(2)
  80. expect(last(o.ops).equals(new InsertOp('abcxyz'))).to.be.true
  81. o.remove('d')
  82. expect(o.ops.length).to.equal(3)
  83. expect(last(o.ops).equals(new RemoveOp(1))).to.be.true
  84. o.remove('d')
  85. expect(o.ops.length).to.equal(3)
  86. expect(last(o.ops).equals(new RemoveOp(2))).to.be.true
  87. })
  88. it('checks for no-ops', function () {
  89. const o = new TextOperation()
  90. expect(o.isNoop()).to.be.true
  91. o.retain(5)
  92. expect(o.isNoop()).to.be.true
  93. o.retain(3)
  94. expect(o.isNoop()).to.be.true
  95. o.insert('lorem')
  96. expect(o.isNoop()).to.be.false
  97. })
  98. it('converts to string', function () {
  99. const o = new TextOperation()
  100. o.retain(2)
  101. o.insert('lorem')
  102. o.remove('ipsum')
  103. o.retain(5)
  104. expect(o.toString()).to.equal(
  105. "retain 2, insert 'lorem', remove 5, retain 5"
  106. )
  107. })
  108. it('converts from JSON', function () {
  109. const ops = [2, -1, -1, 'cde']
  110. const o = TextOperation.fromJSON({ textOperation: ops })
  111. expect(o.ops.length).to.equal(3)
  112. expect(o.baseLength).to.equal(4)
  113. expect(o.targetLength).to.equal(5)
  114. function assertIncorrectAfter(fn) {
  115. const ops2 = ops.slice(0)
  116. fn(ops2)
  117. expect(() => {
  118. TextOperation.fromJSON({ textOperation: ops2 })
  119. }).to.throw
  120. }
  121. assertIncorrectAfter(ops2 => {
  122. ops2.push({ insert: 'x' })
  123. })
  124. assertIncorrectAfter(ops2 => {
  125. ops2.push(null)
  126. })
  127. })
  128. it(
  129. 'applies (randomised)',
  130. random.test(numTrials, () => {
  131. const str = random.string(50)
  132. const comments = random.comments(6)
  133. const o = randomOperation(str, comments.ids)
  134. try {
  135. expect(str.length).to.equal(o.baseLength)
  136. const file = new StringFileData(str, comments.comments)
  137. o.apply(file)
  138. const result = file.getContent()
  139. expect(result.length).to.equal(o.targetLength)
  140. } catch (err) {
  141. if (err instanceof Error) {
  142. err.message = `Failing inputs:\n str: ${JSON.stringify(str)}\n comments: ${JSON.stringify(comments)}\n o: ${JSON.stringify(o.toJSON())}\n\n${err.message}`
  143. }
  144. throw err
  145. }
  146. })
  147. )
  148. it(
  149. 'converts to/from JSON (randomised)',
  150. random.test(numTrials, () => {
  151. const doc = random.string(50)
  152. const comments = random.comments(2)
  153. const operation = randomOperation(doc, comments.ids)
  154. try {
  155. const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
  156. expect(operation.equals(roundTripOperation)).to.be.true
  157. } catch (err) {
  158. if (err instanceof Error) {
  159. err.message = `Failing inputs:\n doc: ${JSON.stringify(doc)}\n comments: ${JSON.stringify(comments)}\n operation: ${JSON.stringify(operation.toJSON())}\n\n${err.message}`
  160. }
  161. throw err
  162. }
  163. })
  164. )
  165. it('throws when invalid operations are applied', function () {
  166. const operation = new TextOperation().retain(1)
  167. expect(() => {
  168. operation.apply(new StringFileData(''))
  169. }).to.throw(TextOperation.ApplyError)
  170. expect(() => {
  171. operation.apply(new StringFileData(' '))
  172. }).not.to.throw
  173. })
  174. it('throws when insert text contains non BMP chars', function () {
  175. const operation = new TextOperation()
  176. const str = '𝌆\n'
  177. expect(() => {
  178. operation.insert(str)
  179. }).to.throw(
  180. TextOperation.UnprocessableError,
  181. /inserted text contains non BMP characters/
  182. )
  183. })
  184. it('throws when base string contains non BMP chars', function () {
  185. const operation = new TextOperation()
  186. const str = '𝌆\n'
  187. expect(() => {
  188. operation.apply(new StringFileData(str))
  189. }).to.throw(
  190. TextOperation.UnprocessableError,
  191. /string contains non BMP characters/
  192. )
  193. })
  194. it('throws at from JSON when it contains non BMP chars', function () {
  195. const operation = ['𝌆\n']
  196. expect(() => {
  197. TextOperation.fromJSON({ textOperation: operation })
  198. }).to.throw(
  199. TextOperation.UnprocessableError,
  200. /inserted text contains non BMP characters/
  201. )
  202. })
  203. describe('invert', function () {
  204. it(
  205. 'inverts (randomised)',
  206. random.test(numTrials, () => {
  207. const str = random.string(50)
  208. const comments = random.comments(6)
  209. const o = randomOperation(str, comments.ids)
  210. try {
  211. const originalFile = new StringFileData(str, comments.comments)
  212. const p = o.invert(originalFile)
  213. expect(o.baseLength).to.equal(p.targetLength)
  214. expect(o.targetLength).to.equal(p.baseLength)
  215. const file = new StringFileData(str, comments.comments)
  216. o.apply(file)
  217. p.apply(file)
  218. const result = file.toRaw()
  219. expect(result).to.deep.equal(originalFile.toRaw())
  220. } catch (err) {
  221. if (err instanceof Error) {
  222. err.message = `Failing inputs:\n str: ${JSON.stringify(str)}\n comments: ${JSON.stringify(comments)}\n o: ${JSON.stringify(o.toJSON())}\n\n${err.message}`
  223. }
  224. throw err
  225. }
  226. })
  227. )
  228. it('re-inserts removed range and comment when inverting', function () {
  229. expectInverseToLeadToInitialState(
  230. new StringFileData(
  231. 'foo bar baz',
  232. [{ id: 'comment1', ranges: [{ pos: 4, length: 3 }] }],
  233. [
  234. {
  235. range: { pos: 4, length: 3 },
  236. tracking: {
  237. ts: '2024-01-01T00:00:00.000Z',
  238. type: 'insert',
  239. userId: 'user1',
  240. },
  241. },
  242. ]
  243. ),
  244. new TextOperation().retain(4).remove(4).retain(3)
  245. )
  246. })
  247. it('deletes inserted range and comment when inverting', function () {
  248. expectInverseToLeadToInitialState(
  249. new StringFileData('foo baz', [
  250. { id: 'comment1', ranges: [], resolved: false },
  251. ]),
  252. new TextOperation()
  253. .retain(4)
  254. .insert('bar', {
  255. commentIds: ['comment1'],
  256. tracking: TrackingProps.fromRaw({
  257. ts: '2024-01-01T00:00:00.000Z',
  258. type: 'insert',
  259. userId: 'user1',
  260. }),
  261. })
  262. .insert(' ')
  263. .retain(3)
  264. )
  265. })
  266. it('removes a tracked delete', function () {
  267. expectInverseToLeadToInitialState(
  268. new StringFileData('foo bar baz'),
  269. new TextOperation()
  270. .retain(4)
  271. .retain(4, {
  272. tracking: TrackingProps.fromRaw({
  273. ts: '2023-01-01T00:00:00.000Z',
  274. type: 'delete',
  275. userId: 'user1',
  276. }),
  277. })
  278. .retain(3)
  279. )
  280. })
  281. it('restores comments that were removed', function () {
  282. expectInverseToLeadToInitialState(
  283. new StringFileData('foo bar baz', [
  284. {
  285. id: 'comment1',
  286. ranges: [{ pos: 4, length: 3 }],
  287. resolved: false,
  288. },
  289. ]),
  290. new TextOperation().retain(4).remove(4).retain(3)
  291. )
  292. })
  293. it('re-inserting removed part of comment restores original comment range', function () {
  294. expectInverseToLeadToInitialState(
  295. new StringFileData('foo bar baz', [
  296. {
  297. id: 'comment1',
  298. ranges: [{ pos: 0, length: 11 }],
  299. resolved: false,
  300. },
  301. ]),
  302. new TextOperation().retain(4).remove(4).retain(3)
  303. )
  304. })
  305. it('re-inserting removed part of tracked change restores tracked change range', function () {
  306. expectInverseToLeadToInitialState(
  307. new StringFileData('foo bar baz', undefined, [
  308. {
  309. range: { pos: 0, length: 11 },
  310. tracking: {
  311. ts: '2023-01-01T00:00:00.000Z',
  312. type: 'delete',
  313. userId: 'user1',
  314. },
  315. },
  316. ]),
  317. new TextOperation().retain(4).remove(4).retain(3)
  318. )
  319. })
  320. it('undoing a tracked delete restores the tracked changes', function () {
  321. expectInverseToLeadToInitialState(
  322. new StringFileData(
  323. 'the quick brown fox jumps over the lazy dog',
  324. undefined,
  325. [
  326. {
  327. range: { pos: 5, length: 5 },
  328. tracking: {
  329. ts: '2023-01-01T00:00:00.000Z',
  330. type: 'insert',
  331. userId: 'user1',
  332. },
  333. },
  334. {
  335. range: { pos: 12, length: 3 },
  336. tracking: {
  337. ts: '2023-01-01T00:00:00.000Z',
  338. type: 'delete',
  339. userId: 'user1',
  340. },
  341. },
  342. {
  343. range: { pos: 18, length: 5 },
  344. tracking: {
  345. ts: '2023-01-01T00:00:00.000Z',
  346. type: 'insert',
  347. userId: 'user1',
  348. },
  349. },
  350. ]
  351. ),
  352. new TextOperation()
  353. .retain(7)
  354. .retain(13, {
  355. tracking: new TrackingProps('delete', 'user1', new Date()),
  356. })
  357. .retain(23)
  358. )
  359. })
  360. })
  361. describe('compose', function () {
  362. it(
  363. 'composes (randomised)',
  364. random.test(numTrials, () => {
  365. // invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
  366. const str = random.string(20)
  367. const comments = random.comments(6)
  368. const a = randomOperation(str, comments.ids)
  369. const file = new StringFileData(str, comments.comments)
  370. a.apply(file)
  371. const afterA = file.toRaw()
  372. const b = randomOperation(afterA.content, comments.ids)
  373. try {
  374. expect(afterA.content.length).to.equal(a.targetLength)
  375. b.apply(file)
  376. const afterB = file.toRaw()
  377. expect(afterB.content.length).to.equal(b.targetLength)
  378. const ab = a.compose(b)
  379. expect(ab.targetLength).to.equal(b.targetLength)
  380. ab.apply(new StringFileData(str, comments.comments))
  381. const afterAB = file.toRaw()
  382. expect(afterAB).to.deep.equal(afterB)
  383. } catch (err) {
  384. if (err instanceof Error) {
  385. err.message = `Failing inputs:\n str: ${JSON.stringify(str)}\n comments: ${JSON.stringify(comments)}\n a: ${JSON.stringify(a.toJSON())}\n b: ${JSON.stringify(b.toJSON())}\n\n${err.message}`
  386. }
  387. throw err
  388. }
  389. })
  390. )
  391. it('composes two operations with comments', function () {
  392. expect(
  393. compose(
  394. new StringFileData('foo baz', [
  395. { id: 'comment1', ranges: [], resolved: false },
  396. ]),
  397. new TextOperation()
  398. .retain(4)
  399. .insert('bar', {
  400. commentIds: ['comment1'],
  401. tracking: TrackingProps.fromRaw({
  402. ts: '2024-01-01T00:00:00.000Z',
  403. type: 'insert',
  404. userId: 'user1',
  405. }),
  406. })
  407. .insert(' ')
  408. .retain(3),
  409. new TextOperation().retain(4).remove(4).retain(3)
  410. )
  411. ).to.deep.equal({
  412. content: 'foo baz',
  413. comments: [{ id: 'comment1', ranges: [] }],
  414. })
  415. })
  416. it('prioritizes tracked changes info from the latter operation', function () {
  417. expect(
  418. compose(
  419. new StringFileData('foo bar baz'),
  420. new TextOperation()
  421. .retain(4)
  422. .retain(4, {
  423. tracking: TrackingProps.fromRaw({
  424. ts: '2023-01-01T00:00:00.000Z',
  425. type: 'delete',
  426. userId: 'user1',
  427. }),
  428. })
  429. .retain(3),
  430. new TextOperation()
  431. .retain(4)
  432. .retain(4, {
  433. tracking: TrackingProps.fromRaw({
  434. ts: '2024-01-01T00:00:00.000Z',
  435. type: 'delete',
  436. userId: 'user2',
  437. }),
  438. })
  439. .retain(3)
  440. )
  441. ).to.deep.equal({
  442. content: 'foo bar baz',
  443. trackedChanges: [
  444. {
  445. range: { pos: 4, length: 4 },
  446. tracking: {
  447. ts: '2024-01-01T00:00:00.000Z',
  448. type: 'delete',
  449. userId: 'user2',
  450. },
  451. },
  452. ],
  453. })
  454. })
  455. it('does not remove tracked change if not overriden by operation 2', function () {
  456. expect(
  457. compose(
  458. new StringFileData('foo bar baz'),
  459. new TextOperation()
  460. .retain(4)
  461. .retain(4, {
  462. tracking: TrackingProps.fromRaw({
  463. ts: '2023-01-01T00:00:00.000Z',
  464. type: 'delete',
  465. userId: 'user1',
  466. }),
  467. })
  468. .retain(3),
  469. new TextOperation().retain(11)
  470. )
  471. ).to.deep.equal({
  472. content: 'foo bar baz',
  473. trackedChanges: [
  474. {
  475. range: { pos: 4, length: 4 },
  476. tracking: {
  477. ts: '2023-01-01T00:00:00.000Z',
  478. type: 'delete',
  479. userId: 'user1',
  480. },
  481. },
  482. ],
  483. })
  484. })
  485. it('adds comment ranges from both operations', function () {
  486. expect(
  487. compose(
  488. new StringFileData('foo bar baz', [
  489. {
  490. id: 'comment1',
  491. ranges: [{ pos: 4, length: 3 }],
  492. resolved: false,
  493. },
  494. {
  495. id: 'comment2',
  496. ranges: [{ pos: 8, length: 3 }],
  497. resolved: false,
  498. },
  499. ]),
  500. new TextOperation()
  501. .retain(5)
  502. .insert('aa', {
  503. commentIds: ['comment1'],
  504. })
  505. .retain(6),
  506. new TextOperation()
  507. .retain(11)
  508. .insert('bb', { commentIds: ['comment2'] })
  509. .retain(2)
  510. )
  511. ).to.deep.equal({
  512. content: 'foo baaar bbbaz',
  513. comments: [
  514. { id: 'comment1', ranges: [{ pos: 4, length: 5 }] },
  515. { id: 'comment2', ranges: [{ pos: 10, length: 5 }] },
  516. ],
  517. })
  518. })
  519. it('it removes the tracking range from a tracked delete if operation 2 resolves it', function () {
  520. expect(
  521. compose(
  522. new StringFileData('foo bar baz'),
  523. new TextOperation()
  524. .retain(4)
  525. .retain(4, {
  526. tracking: TrackingProps.fromRaw({
  527. ts: '2023-01-01T00:00:00.000Z',
  528. type: 'delete',
  529. userId: 'user1',
  530. }),
  531. })
  532. .retain(3),
  533. new TextOperation()
  534. .retain(4)
  535. .retain(4, {
  536. tracking: new ClearTrackingProps(),
  537. })
  538. .retain(3)
  539. )
  540. ).to.deep.equal({
  541. content: 'foo bar baz',
  542. })
  543. })
  544. it('it removes the tracking from an insert if operation 2 resolves it', function () {
  545. expect(
  546. compose(
  547. new StringFileData('foo bar baz'),
  548. new TextOperation()
  549. .retain(4)
  550. .insert('quux ', {
  551. tracking: TrackingProps.fromRaw({
  552. ts: '2023-01-01T00:00:00.000Z',
  553. type: 'insert',
  554. userId: 'user1',
  555. }),
  556. })
  557. .retain(7),
  558. new TextOperation()
  559. .retain(6)
  560. .retain(5, {
  561. tracking: new ClearTrackingProps(),
  562. })
  563. .retain(5)
  564. )
  565. ).to.deep.equal({
  566. content: 'foo quux bar baz',
  567. trackedChanges: [
  568. {
  569. range: { pos: 4, length: 2 },
  570. tracking: {
  571. ts: '2023-01-01T00:00:00.000Z',
  572. type: 'insert',
  573. userId: 'user1',
  574. },
  575. },
  576. ],
  577. })
  578. })
  579. })
  580. describe('transform', function () {
  581. it(
  582. 'transforms (randomised)',
  583. random.test(numTrials, () => {
  584. // invariant: compose(a, b') = compose(b, a')
  585. // where (a', b') = transform(a, b)
  586. const str = random.string(20)
  587. const comments = random.comments(6)
  588. const a = randomOperation(str, comments.ids)
  589. const b = randomOperation(str, comments.ids)
  590. try {
  591. const primes = TextOperation.transform(a, b)
  592. const aPrime = primes[0]
  593. const bPrime = primes[1]
  594. const abPrime = a.compose(bPrime)
  595. const baPrime = b.compose(aPrime)
  596. const abFile = new StringFileData(str, comments.comments)
  597. const baFile = new StringFileData(str, comments.comments)
  598. abPrime.apply(abFile)
  599. baPrime.apply(baFile)
  600. expect(abPrime.equals(baPrime)).to.be.true
  601. expect(abFile.toRaw()).to.deep.equal(baFile.toRaw())
  602. } catch (err) {
  603. if (err instanceof Error) {
  604. err.message = `Failing inputs:\n str: ${JSON.stringify(str)}\n comments: ${JSON.stringify(comments)}\n a: ${JSON.stringify(a.toJSON())}\n b: ${JSON.stringify(b.toJSON())}\n\n${err.message}`
  605. }
  606. throw err
  607. }
  608. })
  609. )
  610. it('adds a tracked change from operation 1', function () {
  611. expect(
  612. transform(
  613. new StringFileData('foo baz'),
  614. new TextOperation()
  615. .retain(4)
  616. .insert('bar', {
  617. tracking: TrackingProps.fromRaw({
  618. ts: '2024-01-01T00:00:00.000Z',
  619. type: 'insert',
  620. userId: 'user1',
  621. }),
  622. })
  623. .insert(' ')
  624. .retain(3),
  625. new TextOperation().retain(7).insert(' qux')
  626. )
  627. ).to.deep.equal({
  628. content: 'foo bar baz qux',
  629. trackedChanges: [
  630. {
  631. range: { pos: 4, length: 3 },
  632. tracking: {
  633. ts: '2024-01-01T00:00:00.000Z',
  634. type: 'insert',
  635. userId: 'user1',
  636. },
  637. },
  638. ],
  639. })
  640. })
  641. it('prioritizes tracked change from the first operation', function () {
  642. expect(
  643. transform(
  644. new StringFileData('foo bar baz'),
  645. new TextOperation()
  646. .retain(4)
  647. .retain(4, {
  648. tracking: TrackingProps.fromRaw({
  649. ts: '2023-01-01T00:00:00.000Z',
  650. type: 'delete',
  651. userId: 'user1',
  652. }),
  653. })
  654. .retain(3),
  655. new TextOperation()
  656. .retain(4)
  657. .retain(4, {
  658. tracking: TrackingProps.fromRaw({
  659. ts: '2024-01-01T00:00:00.000Z',
  660. type: 'delete',
  661. userId: 'user2',
  662. }),
  663. })
  664. .retain(3)
  665. )
  666. ).to.deep.equal({
  667. content: 'foo bar baz',
  668. trackedChanges: [
  669. {
  670. range: { pos: 4, length: 4 },
  671. tracking: {
  672. ts: '2023-01-01T00:00:00.000Z',
  673. type: 'delete',
  674. userId: 'user1',
  675. },
  676. },
  677. ],
  678. })
  679. })
  680. it('splits a tracked change in two to resolve conflicts', function () {
  681. expect(
  682. transform(
  683. new StringFileData('foo bar baz'),
  684. new TextOperation()
  685. .retain(4)
  686. .retain(4, {
  687. tracking: TrackingProps.fromRaw({
  688. ts: '2023-01-01T00:00:00.000Z',
  689. type: 'delete',
  690. userId: 'user1',
  691. }),
  692. })
  693. .retain(3),
  694. new TextOperation()
  695. .retain(4)
  696. .retain(5, {
  697. tracking: TrackingProps.fromRaw({
  698. ts: '2024-01-01T00:00:00.000Z',
  699. type: 'delete',
  700. userId: 'user2',
  701. }),
  702. })
  703. .retain(2)
  704. )
  705. ).to.deep.equal({
  706. content: 'foo bar baz',
  707. trackedChanges: [
  708. {
  709. range: { pos: 4, length: 4 },
  710. tracking: {
  711. ts: '2023-01-01T00:00:00.000Z',
  712. type: 'delete',
  713. userId: 'user1',
  714. },
  715. },
  716. {
  717. range: { pos: 8, length: 1 },
  718. tracking: {
  719. ts: '2024-01-01T00:00:00.000Z',
  720. type: 'delete',
  721. userId: 'user2',
  722. },
  723. },
  724. ],
  725. })
  726. })
  727. it('inserts a tracked change from operation 2 after a tracked change from operation 1', function () {
  728. expect(
  729. transform(
  730. new StringFileData('aaabbbccc'),
  731. new TextOperation()
  732. .retain(3)
  733. .insert('xxx', {
  734. tracking: TrackingProps.fromRaw({
  735. ts: '2023-01-01T00:00:00.000Z',
  736. type: 'insert',
  737. userId: 'user1',
  738. }),
  739. })
  740. .retain(6),
  741. new TextOperation()
  742. .retain(3)
  743. .insert('yyy', {
  744. tracking: TrackingProps.fromRaw({
  745. ts: '2024-01-01T00:00:00.000Z',
  746. type: 'insert',
  747. userId: 'user2',
  748. }),
  749. })
  750. .retain(6)
  751. )
  752. ).to.deep.equal({
  753. content: 'aaaxxxyyybbbccc',
  754. trackedChanges: [
  755. {
  756. range: { pos: 3, length: 3 },
  757. tracking: {
  758. ts: '2023-01-01T00:00:00.000Z',
  759. type: 'insert',
  760. userId: 'user1',
  761. },
  762. },
  763. {
  764. range: { pos: 6, length: 3 },
  765. tracking: {
  766. ts: '2024-01-01T00:00:00.000Z',
  767. type: 'insert',
  768. userId: 'user2',
  769. },
  770. },
  771. ],
  772. })
  773. })
  774. it('preserves a comment even if it is completely removed in one operation', function () {
  775. expect(
  776. transform(
  777. new StringFileData('foo bar baz', [
  778. {
  779. id: 'comment1',
  780. ranges: [{ pos: 4, length: 3 }],
  781. resolved: false,
  782. },
  783. ]),
  784. new TextOperation().retain(4).remove(4).retain(3),
  785. new TextOperation()
  786. .retain(7)
  787. .insert('qux ', {
  788. commentIds: ['comment1'],
  789. })
  790. .retain(4)
  791. )
  792. ).to.deep.equal({
  793. content: 'foo qux baz',
  794. comments: [{ id: 'comment1', ranges: [{ pos: 4, length: 4 }] }],
  795. })
  796. })
  797. it('extends a comment to both ranges if both operations add text in it', function () {
  798. expect(
  799. transform(
  800. new StringFileData('foo bar baz', [
  801. {
  802. id: 'comment1',
  803. ranges: [{ pos: 4, length: 3 }],
  804. resolved: false,
  805. },
  806. ]),
  807. new TextOperation()
  808. .retain(4)
  809. .insert('qux ', {
  810. commentIds: ['comment1'],
  811. })
  812. .retain(7),
  813. new TextOperation()
  814. .retain(4)
  815. .insert('corge ', { commentIds: ['comment1'] })
  816. .retain(7)
  817. )
  818. ).to.deep.equal({
  819. content: 'foo qux corge bar baz',
  820. comments: [{ id: 'comment1', ranges: [{ pos: 4, length: 13 }] }],
  821. })
  822. })
  823. it('adds a tracked change from both operations at different places', function () {
  824. expect(
  825. transform(
  826. new StringFileData('foo bar baz'),
  827. new TextOperation()
  828. .retain(4)
  829. .insert('qux ', {
  830. tracking: TrackingProps.fromRaw({
  831. ts: '2023-01-01T00:00:00.000Z',
  832. type: 'insert',
  833. userId: 'user1',
  834. }),
  835. })
  836. .retain(7),
  837. new TextOperation()
  838. .retain(8)
  839. .insert('corge ', {
  840. tracking: TrackingProps.fromRaw({
  841. ts: '2024-01-01T00:00:00.000Z',
  842. type: 'insert',
  843. userId: 'user2',
  844. }),
  845. })
  846. .retain(3)
  847. )
  848. ).to.deep.equal({
  849. content: 'foo qux bar corge baz',
  850. trackedChanges: [
  851. {
  852. range: { pos: 4, length: 4 },
  853. tracking: {
  854. ts: '2023-01-01T00:00:00.000Z',
  855. type: 'insert',
  856. userId: 'user1',
  857. },
  858. },
  859. {
  860. range: { pos: 12, length: 6 },
  861. tracking: {
  862. ts: '2024-01-01T00:00:00.000Z',
  863. type: 'insert',
  864. userId: 'user2',
  865. },
  866. },
  867. ],
  868. })
  869. })
  870. })
  871. })
  872. function expectInverseToLeadToInitialState(fileData, operation) {
  873. const initialState = fileData
  874. const result = initialState.toRaw()
  875. const invertedOperation = operation.invert(initialState)
  876. operation.apply(initialState)
  877. invertedOperation.apply(initialState)
  878. const invertedResult = initialState.toRaw()
  879. expect(invertedResult).to.deep.equal(result)
  880. }
  881. function compose(fileData, op1, op2) {
  882. const copy = StringFileData.fromRaw(fileData.toRaw())
  883. op1.apply(fileData)
  884. op2.apply(fileData)
  885. const result1 = fileData.toRaw()
  886. const composed = op1.compose(op2)
  887. composed.apply(copy)
  888. const result2 = copy.toRaw()
  889. expect(result1).to.deep.equal(result2)
  890. return fileData.toRaw()
  891. }
  892. function transform(fileData, a, b) {
  893. const initialState = fileData
  894. const aFileData = StringFileData.fromRaw(initialState.toRaw())
  895. const bFileData = StringFileData.fromRaw(initialState.toRaw())
  896. const [aPrime, bPrime] = TextOperation.transform(a, b)
  897. a.apply(aFileData)
  898. bPrime.apply(aFileData)
  899. b.apply(bFileData)
  900. aPrime.apply(bFileData)
  901. const resultA = aFileData.toRaw()
  902. const resultB = bFileData.toRaw()
  903. expect(resultA).to.deep.equal(resultB)
  904. return aFileData.toRaw()
  905. }