SnapshotManagerTests.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968
  1. import sinon from 'sinon'
  2. import { expect } from 'chai'
  3. import { strict as esmock } from 'esmock'
  4. import Core from 'overleaf-editor-core'
  5. import * as Errors from '../../../../app/js/Errors.js'
  6. const MODULE_PATH = '../../../../app/js/SnapshotManager.js'
  7. describe('SnapshotManager', function () {
  8. beforeEach(async function () {
  9. this.HistoryStoreManager = {
  10. getBlobStore: sinon.stub(),
  11. promises: {
  12. getChunkAtVersion: sinon.stub(),
  13. getMostRecentChunk: sinon.stub(),
  14. getProjectBlobStream: sinon.stub(),
  15. },
  16. }
  17. this.WebApiManager = {
  18. promises: {
  19. getHistoryId: sinon.stub(),
  20. },
  21. }
  22. this.SnapshotManager = await esmock(MODULE_PATH, {
  23. 'overleaf-editor-core': Core,
  24. '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
  25. '../../../../app/js/WebApiManager.js': this.WebApiManager,
  26. '../../../../app/js/Errors.js': Errors,
  27. })
  28. this.projectId = 'project-id-123'
  29. this.historyId = 'ol-project-id-123'
  30. this.callback = sinon.stub()
  31. })
  32. describe('getFileSnapshotStream', function () {
  33. beforeEach(function () {
  34. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  35. this.ranges = {
  36. comments: [],
  37. trackedChanges: [
  38. {
  39. range: { pos: 4, length: 6 },
  40. tracking: {
  41. userId: 'user-1',
  42. ts: '2024-01-01T00:00:00.000Z',
  43. type: 'delete',
  44. },
  45. },
  46. {
  47. range: { pos: 35, length: 5 },
  48. tracking: {
  49. userId: 'user-1',
  50. ts: '2024-01-01T00:00:00.000Z',
  51. type: 'insert',
  52. },
  53. },
  54. ],
  55. }
  56. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  57. chunk: {
  58. history: {
  59. snapshot: {
  60. files: {
  61. 'main.tex': {
  62. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  63. stringLength: 41,
  64. },
  65. 'file_with_ranges.tex': {
  66. hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
  67. rangesHash: '73061952d41ce54825e2fc1c36b4cf736d5fb62f',
  68. stringLength: 41,
  69. },
  70. 'binary.png': {
  71. hash: 'c6654ea913979e13e22022653d284444f284a172',
  72. byteLength: 41,
  73. },
  74. },
  75. },
  76. changes: [
  77. {
  78. operations: [
  79. {
  80. pathname: 'main.tex',
  81. textOperation: [41, '\n\nSeven eight'],
  82. },
  83. ],
  84. timestamp: '2017-12-04T10:29:17.786Z',
  85. authors: [31],
  86. },
  87. {
  88. operations: [
  89. {
  90. pathname: 'main.tex',
  91. textOperation: [54, ' nine'],
  92. },
  93. ],
  94. timestamp: '2017-12-04T10:29:22.905Z',
  95. authors: [31],
  96. },
  97. ],
  98. },
  99. startVersion: 3,
  100. authors: [
  101. {
  102. id: 31,
  103. email: 'james.allen@overleaf.com',
  104. name: 'James',
  105. },
  106. ],
  107. },
  108. })
  109. })
  110. describe('of a text file with no tracked changes', function () {
  111. beforeEach(async function () {
  112. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  113. getString: (this.getString = sinon.stub().resolves(
  114. `\
  115. Hello world
  116. One two three
  117. Four five six\
  118. `.replace(/^\t/g, '')
  119. )),
  120. getObject: sinon.stub().rejects(),
  121. })
  122. this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
  123. this.projectId,
  124. 5,
  125. 'main.tex'
  126. )
  127. })
  128. it('should get the overleaf id', function () {
  129. this.WebApiManager.promises.getHistoryId
  130. .calledWith(this.projectId)
  131. .should.equal(true)
  132. })
  133. it('should get the chunk', function () {
  134. this.HistoryStoreManager.promises.getChunkAtVersion
  135. .calledWith(this.projectId, this.historyId, 5)
  136. .should.equal(true)
  137. })
  138. it('should get the blob of the starting snapshot', function () {
  139. this.getString
  140. .calledWith('35c9bd86574d61dcadbce2fdd3d4a0684272c6ea')
  141. .should.equal(true)
  142. })
  143. it('should return a string stream with the text content', function () {
  144. expect(this.stream.read().toString()).to.equal(
  145. `\
  146. Hello world
  147. One two three
  148. Four five six
  149. Seven eight nine\
  150. `.replace(/^\t/g, '')
  151. )
  152. })
  153. describe('on blob store error', function () {
  154. beforeEach(function () {
  155. this.error = new Error('ESOCKETTIMEDOUT')
  156. this.HistoryStoreManager.getBlobStore
  157. .withArgs(this.historyId)
  158. .returns({
  159. getString: sinon.stub().rejects(this.error),
  160. getObject: sinon.stub().rejects(this.error),
  161. })
  162. })
  163. it('should call back with error', async function () {
  164. await expect(
  165. this.SnapshotManager.promises.getFileSnapshotStream(
  166. this.projectId,
  167. 5,
  168. 'main.tex'
  169. )
  170. ).to.be.rejectedWith(this.error)
  171. })
  172. })
  173. })
  174. describe('of a text file with tracked changes', function () {
  175. beforeEach(async function () {
  176. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  177. getString: (this.getString = sinon
  178. .stub()
  179. .resolves('the quick brown fox jumps over the lazy dog')),
  180. getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
  181. })
  182. this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
  183. this.projectId,
  184. 5,
  185. 'file_with_ranges.tex'
  186. )
  187. })
  188. it('should get the overleaf id', function () {
  189. this.WebApiManager.promises.getHistoryId
  190. .calledWith(this.projectId)
  191. .should.equal(true)
  192. })
  193. it('should get the chunk', function () {
  194. this.HistoryStoreManager.promises.getChunkAtVersion
  195. .calledWith(this.projectId, this.historyId, 5)
  196. .should.equal(true)
  197. })
  198. it('should get the blob of the starting snapshot', function () {
  199. this.getString
  200. .calledWith('5d2781d78fa5a97b7bafa849fe933dfc9dc93eba')
  201. .should.equal(true)
  202. })
  203. it('should get the blob of the ranges', function () {
  204. this.getObject
  205. .calledWith('73061952d41ce54825e2fc1c36b4cf736d5fb62f')
  206. .should.equal(true)
  207. })
  208. it('should return a string stream with the text content without the tracked deletes', function () {
  209. expect(this.stream.read().toString()).to.equal(
  210. 'the brown fox jumps over the lazy dog'
  211. )
  212. })
  213. })
  214. describe('of a binary file', function () {
  215. beforeEach(async function () {
  216. this.HistoryStoreManager.promises.getProjectBlobStream
  217. .withArgs(this.historyId)
  218. .resolves((this.stream = 'mock-stream'))
  219. this.returnedStream =
  220. await this.SnapshotManager.promises.getFileSnapshotStream(
  221. this.projectId,
  222. 5,
  223. 'binary.png'
  224. )
  225. })
  226. it('should get the overleaf id', function () {
  227. this.WebApiManager.promises.getHistoryId
  228. .calledWith(this.projectId)
  229. .should.equal(true)
  230. })
  231. it('should get the chunk', function () {
  232. this.HistoryStoreManager.promises.getChunkAtVersion
  233. .calledWith(this.projectId, this.historyId, 5)
  234. .should.equal(true)
  235. })
  236. it('should get the blob of the starting snapshot', function () {
  237. this.HistoryStoreManager.promises.getProjectBlobStream
  238. .calledWith(
  239. this.historyId,
  240. 'c6654ea913979e13e22022653d284444f284a172'
  241. )
  242. .should.equal(true)
  243. })
  244. it('should return a stream with the blob content', function () {
  245. expect(this.returnedStream).to.equal(this.stream)
  246. })
  247. })
  248. describe("when the file doesn't exist", function () {
  249. it('should return a NotFoundError', async function () {
  250. await expect(
  251. this.SnapshotManager.promises.getFileSnapshotStream(
  252. this.projectId,
  253. 5,
  254. 'not-here.png'
  255. )
  256. ).to.be.rejectedWith(Errors.NotFoundError)
  257. })
  258. })
  259. })
  260. describe('getProjectSnapshot', function () {
  261. beforeEach(function () {
  262. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  263. this.ranges = {
  264. comments: [],
  265. trackedChanges: [
  266. {
  267. range: { pos: 5, length: 6 },
  268. tracking: {
  269. userId: 'user-1',
  270. ts: '2024-01-01T00:00:00.000Z',
  271. type: 'delete',
  272. },
  273. },
  274. {
  275. range: { pos: 12, length: 5 },
  276. tracking: {
  277. userId: 'user-1',
  278. ts: '2024-01-01T00:00:00.000Z',
  279. type: 'insert',
  280. },
  281. },
  282. ],
  283. }
  284. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  285. chunk: (this.chunk = {
  286. history: {
  287. snapshot: {
  288. files: {
  289. 'main.tex': {
  290. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  291. stringLength: 41,
  292. },
  293. 'unchanged.tex': {
  294. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  295. stringLength: 41,
  296. },
  297. 'with_ranges_unchanged.tex': {
  298. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  299. rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
  300. stringLength: 41,
  301. },
  302. 'with_ranges_changed.tex': {
  303. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  304. rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
  305. stringLength: 41,
  306. },
  307. 'binary.png': {
  308. hash: 'c6654ea913979e13e22022653d284444f284a172',
  309. byteLength: 41,
  310. },
  311. },
  312. },
  313. changes: [
  314. {
  315. operations: [
  316. {
  317. pathname: 'main.tex',
  318. textOperation: [41, '\n\nSeven eight'],
  319. },
  320. ],
  321. timestamp: '2017-12-04T10:29:17.786Z',
  322. authors: [31],
  323. },
  324. {
  325. operations: [
  326. {
  327. pathname: 'main.tex',
  328. textOperation: [54, ' nine'],
  329. },
  330. ],
  331. timestamp: '2017-12-04T10:29:22.905Z',
  332. authors: [31],
  333. },
  334. {
  335. operations: [
  336. {
  337. pathname: 'with_ranges_changed.tex',
  338. textOperation: [41, '\n\nSeven eight'],
  339. },
  340. ],
  341. timestamp: '2017-12-04T10:29:25.905Z',
  342. authors: [31],
  343. },
  344. ],
  345. },
  346. startVersion: 3,
  347. authors: [
  348. {
  349. id: 31,
  350. email: 'james.allen@overleaf.com',
  351. name: 'James',
  352. },
  353. ],
  354. }),
  355. })
  356. })
  357. describe('of project', function () {
  358. beforeEach(async function () {
  359. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  360. getString: (this.getString = sinon.stub().resolves(
  361. `\
  362. Hello world
  363. One two three
  364. Four five six\
  365. `
  366. )),
  367. getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
  368. })
  369. this.data = await this.SnapshotManager.promises.getProjectSnapshot(
  370. this.projectId,
  371. 6
  372. )
  373. })
  374. it('should get the overleaf id', function () {
  375. this.WebApiManager.promises.getHistoryId
  376. .calledWith(this.projectId)
  377. .should.equal(true)
  378. })
  379. it('should get the chunk', function () {
  380. this.HistoryStoreManager.promises.getChunkAtVersion
  381. .calledWith(this.projectId, this.historyId, 6)
  382. .should.equal(true)
  383. })
  384. it('should get the ranges for the file with tracked changes', function () {
  385. this.getObject.calledWith('2e59fe3dbd5310703f89236d589d0b35db169cdf')
  386. })
  387. it('should produce the snapshot file data', function () {
  388. expect(this.data).to.deep.equal({
  389. files: {
  390. 'main.tex': {
  391. // files with operations in the chunk should return content only
  392. data: {
  393. content:
  394. 'Hello world\n\nOne two three\n\nFour five six\n\nSeven eight nine',
  395. },
  396. },
  397. 'unchanged.tex': {
  398. // unchanged files in the chunk should return hash only
  399. data: {
  400. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  401. },
  402. },
  403. 'with_ranges_changed.tex': {
  404. // files in the chunk with tracked changes should return content
  405. // without the tracked deletes
  406. data: {
  407. content:
  408. 'Hello\n\nOne two three\n\nFour five six\n\nSeven eight',
  409. },
  410. },
  411. 'with_ranges_unchanged.tex': {
  412. // files in the chunk with tracked changes should return content
  413. // without the tracked deletes, even if they are unchanged
  414. data: {
  415. content: 'Hello\n\nOne two three\n\nFour five six',
  416. },
  417. },
  418. 'binary.png': {
  419. // binary files in the chunk should return hash only
  420. data: {
  421. hash: 'c6654ea913979e13e22022653d284444f284a172',
  422. },
  423. },
  424. },
  425. projectId: 'project-id-123',
  426. })
  427. })
  428. })
  429. describe('on blob store error', function () {
  430. beforeEach(function () {
  431. this.error = new Error('ESOCKETTIMEDOUT')
  432. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  433. getString: sinon.stub().rejects(this.error),
  434. getObject: sinon.stub().resolves(),
  435. })
  436. })
  437. it('should call back with error', async function () {
  438. expect(
  439. this.SnapshotManager.promises.getProjectSnapshot(this.projectId, 5)
  440. ).to.be.rejectedWith(this.error.message)
  441. })
  442. })
  443. })
  444. describe('getLatestSnapshot', function () {
  445. describe('for a project', function () {
  446. beforeEach(async function () {
  447. this.HistoryStoreManager.promises.getMostRecentChunk.resolves({
  448. chunk: (this.chunk = {
  449. history: {
  450. snapshot: {
  451. files: {
  452. 'main.tex': {
  453. hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
  454. stringLength: 41,
  455. },
  456. 'binary.png': {
  457. hash: 'c6654ea913979e13e22022653d284444f284a172',
  458. byteLength: 41,
  459. },
  460. },
  461. },
  462. changes: [
  463. {
  464. operations: [
  465. {
  466. pathname: 'main.tex',
  467. textOperation: [41, '\n\nSeven eight'],
  468. },
  469. ],
  470. timestamp: '2017-12-04T10:29:17.786Z',
  471. authors: [31],
  472. },
  473. {
  474. operations: [
  475. {
  476. pathname: 'main.tex',
  477. textOperation: [54, ' nine'],
  478. },
  479. ],
  480. timestamp: '2017-12-04T10:29:22.905Z',
  481. authors: [31],
  482. },
  483. ],
  484. },
  485. startVersion: 3,
  486. authors: [
  487. {
  488. id: 31,
  489. email: 'james.allen@overleaf.com',
  490. name: 'James',
  491. },
  492. ],
  493. }),
  494. })
  495. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  496. getString: (this.getString = sinon.stub().resolves(
  497. `\
  498. Hello world
  499. One two three
  500. Four five six\
  501. `.replace(/^\t/g, '')
  502. )),
  503. getObject: sinon.stub().rejects(),
  504. })
  505. this.data = await this.SnapshotManager.promises.getLatestSnapshot(
  506. this.projectId,
  507. this.historyId
  508. )
  509. })
  510. it('should get the chunk', function () {
  511. this.HistoryStoreManager.promises.getMostRecentChunk
  512. .calledWith(this.projectId, this.historyId)
  513. .should.equal(true)
  514. })
  515. it('should produce the snapshot file data', function () {
  516. expect(this.data).to.have.all.keys(['main.tex', 'binary.png'])
  517. expect(this.data['main.tex']).to.exist
  518. expect(this.data['binary.png']).to.exist
  519. expect(this.data['main.tex'].getStringLength()).to.equal(59)
  520. expect(this.data['binary.png'].getByteLength()).to.equal(41)
  521. expect(this.data['binary.png'].getHash()).to.equal(
  522. 'c6654ea913979e13e22022653d284444f284a172'
  523. )
  524. })
  525. })
  526. describe('when the chunk is empty', function () {
  527. beforeEach(async function () {
  528. this.HistoryStoreManager.promises.getMostRecentChunk.resolves(null)
  529. expect(
  530. this.SnapshotManager.promises.getLatestSnapshot(
  531. this.projectId,
  532. this.historyId
  533. )
  534. ).to.be.rejectedWith('undefined chunk')
  535. })
  536. })
  537. })
  538. describe('getRangesSnapshot', function () {
  539. beforeEach(async function () {
  540. this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
  541. this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
  542. chunk: (this.chunk = {
  543. history: {
  544. snapshot: {
  545. files: {
  546. 'main.tex': {
  547. hash: (this.fileHash =
  548. '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
  549. rangesHash: (this.rangesHash =
  550. '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
  551. stringLength: 41,
  552. },
  553. },
  554. },
  555. changes: [],
  556. },
  557. startVersion: 1,
  558. authors: [
  559. {
  560. id: 31,
  561. email: 'author@example.com',
  562. name: 'Author',
  563. },
  564. ],
  565. }),
  566. })
  567. this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
  568. getString: (this.getString = sinon.stub()),
  569. getObject: (this.getObject = sinon.stub()),
  570. })
  571. this.getString.resolves('the quick brown fox jumps over the lazy dog')
  572. })
  573. describe('with tracked deletes', function () {
  574. beforeEach(async function () {
  575. this.getObject.resolves({
  576. trackedChanges: [
  577. {
  578. // 'quick '
  579. range: {
  580. pos: 4,
  581. length: 6,
  582. },
  583. tracking: {
  584. type: 'delete',
  585. userId: '31',
  586. ts: '2023-01-01T00:00:00.000Z',
  587. },
  588. },
  589. {
  590. // 'fox '
  591. range: {
  592. pos: 16,
  593. length: 4,
  594. },
  595. tracking: {
  596. type: 'delete',
  597. userId: '31',
  598. ts: '2023-01-01T00:00:00.000Z',
  599. },
  600. },
  601. {
  602. // 'lazy '
  603. range: {
  604. pos: 35,
  605. length: 5,
  606. },
  607. tracking: {
  608. type: 'insert',
  609. userId: '31',
  610. ts: '2023-01-01T00:00:00.000Z',
  611. },
  612. },
  613. {
  614. // 'dog'
  615. range: {
  616. pos: 40,
  617. length: 3,
  618. },
  619. tracking: {
  620. type: 'delete',
  621. userId: '31',
  622. ts: '2023-01-01T00:00:00.000Z',
  623. },
  624. },
  625. ],
  626. })
  627. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  628. this.projectId,
  629. 1,
  630. 'main.tex'
  631. )
  632. })
  633. it("doesn't shift the tracked delete by itself", function () {
  634. expect(this.data.changes[0].op.p).to.eq(4)
  635. })
  636. it('should move subsequent tracked changes by the length of previous deletes', function () {
  637. expect(this.data.changes[1].op.p).to.eq(16 - 6)
  638. expect(this.data.changes[2].op.p).to.eq(35 - 6 - 4)
  639. })
  640. it("shouldn't move subsequent tracked changes by previous inserts", function () {
  641. expect(this.data.changes[3].op.p).to.eq(40 - 6 - 4)
  642. })
  643. })
  644. describe('with comments and tracked deletes', function () {
  645. beforeEach(async function () {
  646. this.getObject.resolves({
  647. // the quick brown fox jumps over the lazy dog
  648. trackedChanges: [
  649. {
  650. // 'e qui'
  651. range: {
  652. pos: 2,
  653. length: 5,
  654. },
  655. tracking: {
  656. type: 'delete',
  657. userId: '31',
  658. ts: '2023-01-01T00:00:00.000Z',
  659. },
  660. },
  661. {
  662. // 'r'
  663. range: {
  664. pos: 11,
  665. length: 1,
  666. },
  667. tracking: {
  668. type: 'delete',
  669. userId: '31',
  670. ts: '2023-01-01T00:00:00.000Z',
  671. },
  672. },
  673. {
  674. // 'er th'
  675. range: {
  676. pos: 28,
  677. length: 5,
  678. },
  679. tracking: {
  680. type: 'delete',
  681. userId: '31',
  682. ts: '2023-01-01T00:00:00.000Z',
  683. },
  684. },
  685. ],
  686. comments: [
  687. {
  688. id: 'comment-1',
  689. ranges: [
  690. // 'quick'
  691. {
  692. pos: 4,
  693. length: 5,
  694. },
  695. // 'brown'
  696. {
  697. pos: 10,
  698. length: 5,
  699. },
  700. // 'over'
  701. {
  702. pos: 26,
  703. length: 4,
  704. },
  705. ],
  706. resolved: false,
  707. },
  708. ],
  709. })
  710. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  711. this.projectId,
  712. 1,
  713. 'main.tex'
  714. )
  715. })
  716. it('should move the comment to the start of the tracked delete and remove overlapping text', function () {
  717. expect(this.data.comments[0].op.p).to.eq(2)
  718. expect(this.data.comments[0].op.c).to.eq('ck')
  719. })
  720. it('should remove overlapping text in middle of comment', function () {
  721. expect(this.data.comments[1].op.p).to.eq(5)
  722. expect(this.data.comments[1].op.c).to.eq('bown')
  723. })
  724. it('should remove overlapping text at end of comment', function () {
  725. expect(this.data.comments[2].op.p).to.eq(20)
  726. expect(this.data.comments[2].op.c).to.eq('ov')
  727. })
  728. it('should put resolved status in op', function () {
  729. expect(this.data.comments[0].op.resolved).to.be.false
  730. expect(this.data.comments[1].op.resolved).to.be.false
  731. expect(this.data.comments[2].op.resolved).to.be.false
  732. })
  733. })
  734. describe('with multiple tracked changes and comments', function () {
  735. beforeEach(async function () {
  736. this.getObject.resolves({
  737. trackedChanges: [
  738. {
  739. // 'quick '
  740. range: {
  741. pos: 4,
  742. length: 6,
  743. },
  744. tracking: {
  745. type: 'delete',
  746. userId: '31',
  747. ts: '2023-01-01T00:00:00.000Z',
  748. },
  749. },
  750. {
  751. // 'brown '
  752. range: {
  753. pos: 10,
  754. length: 6,
  755. },
  756. tracking: {
  757. type: 'insert',
  758. userId: '31',
  759. ts: '2024-01-01T00:00:00.000Z',
  760. },
  761. },
  762. {
  763. // 'lazy '
  764. range: {
  765. pos: 35,
  766. length: 5,
  767. },
  768. tracking: {
  769. type: 'delete',
  770. userId: '31',
  771. ts: '2024-01-01T00:00:00.000Z',
  772. },
  773. },
  774. ],
  775. comments: [
  776. {
  777. id: 'comment-1',
  778. // 'quick', 'brown', 'lazy'
  779. ranges: [
  780. {
  781. pos: 4,
  782. length: 5,
  783. },
  784. {
  785. pos: 10,
  786. length: 5,
  787. },
  788. {
  789. pos: 35,
  790. length: 4,
  791. },
  792. ],
  793. resolved: false,
  794. },
  795. {
  796. id: 'comment-2',
  797. // 'the', 'the'
  798. ranges: [
  799. {
  800. pos: 0,
  801. length: 3,
  802. },
  803. {
  804. pos: 31,
  805. length: 3,
  806. },
  807. ],
  808. resolved: true,
  809. },
  810. ],
  811. })
  812. this.data = await this.SnapshotManager.promises.getRangesSnapshot(
  813. this.projectId,
  814. 1,
  815. 'main.tex'
  816. )
  817. })
  818. it('looks up ranges', function () {
  819. expect(this.getObject).to.have.been.calledWith(this.rangesHash)
  820. expect(this.getString).to.have.been.calledWith(this.fileHash)
  821. })
  822. it('should get the chunk', function () {
  823. expect(
  824. this.HistoryStoreManager.promises.getChunkAtVersion
  825. ).to.have.been.calledWith(this.projectId, this.historyId, 1)
  826. })
  827. it('returns the ranges with content and adjusted positions to ignore tracked deletes', function () {
  828. expect(this.data).to.deep.equal({
  829. changes: [
  830. {
  831. metadata: {
  832. ts: '2023-01-01T00:00:00.000Z',
  833. user_id: '31',
  834. },
  835. op: {
  836. d: 'quick ',
  837. p: 4,
  838. },
  839. },
  840. {
  841. metadata: {
  842. ts: '2024-01-01T00:00:00.000Z',
  843. user_id: '31',
  844. },
  845. op: {
  846. i: 'brown ',
  847. p: 4,
  848. },
  849. },
  850. {
  851. metadata: {
  852. ts: '2024-01-01T00:00:00.000Z',
  853. user_id: '31',
  854. },
  855. op: {
  856. d: 'lazy ',
  857. p: 29,
  858. },
  859. },
  860. ],
  861. comments: [
  862. {
  863. op: {
  864. c: '',
  865. p: 4,
  866. t: 'comment-1',
  867. resolved: false,
  868. },
  869. },
  870. {
  871. op: {
  872. c: 'brown',
  873. p: 4,
  874. t: 'comment-1',
  875. resolved: false,
  876. },
  877. },
  878. {
  879. op: {
  880. c: '',
  881. p: 29,
  882. t: 'comment-1',
  883. resolved: false,
  884. },
  885. },
  886. {
  887. op: {
  888. c: 'the',
  889. p: 0,
  890. t: 'comment-2',
  891. resolved: true,
  892. },
  893. },
  894. {
  895. op: {
  896. c: 'the',
  897. p: 25,
  898. t: 'comment-2',
  899. resolved: true,
  900. },
  901. },
  902. ],
  903. })
  904. })
  905. })
  906. })
  907. })