text_operation.test.js 29 KB

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