pdf-synctex-controls.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
  2. import { cloneDeep } from 'lodash'
  3. import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
  4. import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
  5. import { useEffect } from 'react'
  6. import {
  7. EditorProviders,
  8. makeEditorOpenDocProvider,
  9. } from '../../helpers/editor-providers'
  10. import { mockScope } from './scope'
  11. import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
  12. import { FindResult } from '@/features/file-tree/util/path'
  13. const mockHighlights = [
  14. {
  15. page: 1,
  16. h: 85.03936,
  17. v: 509.999878,
  18. width: 441.921265,
  19. height: 8.855677,
  20. },
  21. {
  22. page: 1,
  23. h: 85.03936,
  24. v: 486.089539,
  25. width: 441.921265,
  26. height: 8.855677,
  27. },
  28. ]
  29. type Position = {
  30. page: number
  31. offset: { top: number; left: number }
  32. pageSize: { height: number; width: number }
  33. }
  34. const mockPosition: Position = {
  35. page: 1,
  36. offset: { top: 10, left: 10 },
  37. pageSize: { height: 500, width: 500 },
  38. }
  39. const mockSelectedEntities = [{ type: 'doc' }] as FindResult[]
  40. const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
  41. const { setPosition } = useCompileContext()
  42. // mock PDF scroll position update
  43. useEffect(() => {
  44. setPosition(mockPosition)
  45. }, [mockPosition, setPosition])
  46. return null
  47. }
  48. // mock PDF scroll position update
  49. const setDetachedPosition = (mockPosition: Position) => {
  50. testDetachChannel.postMessage({
  51. role: 'detacher',
  52. event: 'state-position',
  53. data: { value: mockPosition },
  54. })
  55. }
  56. const WithSelectedEntities = ({
  57. mockSelectedEntities = [],
  58. }: {
  59. mockSelectedEntities: FindResult[]
  60. }) => {
  61. const { setSelectedEntities } = useFileTreeData()
  62. useEffect(() => {
  63. setSelectedEntities(mockSelectedEntities)
  64. }, [mockSelectedEntities, setSelectedEntities])
  65. return null
  66. }
  67. function mockProviders() {
  68. return {
  69. EditorOpenDocProvider: makeEditorOpenDocProvider({
  70. openDocName: 'main.tex',
  71. currentDocumentId: null,
  72. currentDocument: {
  73. doc_id: 'test-doc',
  74. getSnapshot: () => 'some doc content',
  75. hasBufferedOps: () => false,
  76. on: () => {},
  77. off: () => {},
  78. leaveAndCleanUpPromise: () => Promise.resolve(),
  79. } as any,
  80. }),
  81. }
  82. }
  83. describe('<PdfSynctexControls/>', function () {
  84. beforeEach(function () {
  85. window.metaAttributesCache.set('ol-project_id', 'test-project')
  86. window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
  87. cy.interceptEvents()
  88. })
  89. it('handles clicks on sync buttons', function () {
  90. cy.interceptCompile()
  91. const scope = mockScope()
  92. const providers = mockProviders()
  93. cy.mount(
  94. <EditorProviders scope={scope} providers={providers}>
  95. <WithPosition mockPosition={mockPosition} />
  96. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  97. <PdfSynctexControls />
  98. </EditorProviders>
  99. )
  100. cy.waitForCompile()
  101. cy.get('.synctex-control-icon').should('have.length', 2)
  102. // mock editor cursor position update
  103. cy.window().then(win => {
  104. win.dispatchEvent(
  105. new CustomEvent('cursor:editor:update', {
  106. detail: { row: 100, column: 10 },
  107. })
  108. )
  109. })
  110. cy.wrap(null).then(() => {
  111. setDetachedPosition(mockPosition)
  112. })
  113. cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
  114. syncCodeResponse => {
  115. cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
  116. cy.findByRole('button', { name: 'Go to code location in PDF' })
  117. .should('be.disabled')
  118. .then(() => {
  119. syncCodeResponse.resolve({
  120. body: { pdf: cloneDeep(mockHighlights) },
  121. })
  122. })
  123. cy.wait('@sync-code')
  124. }
  125. )
  126. cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf').then(
  127. syncPdfResponse => {
  128. cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
  129. cy.findByRole('button', { name: /^Go to PDF location in code/ })
  130. .should('be.disabled')
  131. .then(() => {
  132. syncPdfResponse.resolve({
  133. body: { code: [{ file: 'main.tex', line: 100 }] },
  134. })
  135. })
  136. cy.wait('@sync-pdf')
  137. }
  138. )
  139. })
  140. it('disables button when multiple entities are selected', function () {
  141. cy.interceptCompile()
  142. const scope = mockScope()
  143. const providers = mockProviders()
  144. cy.mount(
  145. <EditorProviders scope={scope} providers={providers}>
  146. <WithPosition mockPosition={mockPosition} />
  147. <WithSelectedEntities
  148. mockSelectedEntities={
  149. [{ type: 'doc' }, { type: 'doc' }] as FindResult[]
  150. }
  151. />
  152. <PdfSynctexControls />
  153. </EditorProviders>
  154. )
  155. cy.waitForCompile()
  156. cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
  157. 'be.disabled'
  158. )
  159. })
  160. it('disables button when a file is selected', function () {
  161. cy.interceptCompile()
  162. const scope = mockScope()
  163. const providers = mockProviders()
  164. cy.mount(
  165. <EditorProviders scope={scope} providers={providers}>
  166. <WithPosition mockPosition={mockPosition} />
  167. <WithSelectedEntities
  168. mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
  169. />
  170. <PdfSynctexControls />
  171. </EditorProviders>
  172. )
  173. cy.waitForCompile()
  174. cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
  175. 'be.disabled'
  176. )
  177. })
  178. describe('with detacher role', function () {
  179. beforeEach(function () {
  180. window.metaAttributesCache.set('ol-detachRole', 'detacher')
  181. })
  182. it('does not have go to PDF location button nor arrow icon', function () {
  183. cy.interceptCompile()
  184. const scope = mockScope()
  185. const providers = mockProviders()
  186. cy.mount(
  187. <EditorProviders scope={scope} providers={providers}>
  188. <WithPosition mockPosition={mockPosition} />
  189. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  190. <PdfSynctexControls />
  191. </EditorProviders>
  192. )
  193. cy.waitForCompile()
  194. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  195. 'not.exist'
  196. )
  197. cy.get('.synctex-control-icon').should('not.exist')
  198. })
  199. it('send set highlights action', function () {
  200. cy.interceptCompile()
  201. const scope = mockScope()
  202. const providers = mockProviders()
  203. cy.mount(
  204. <EditorProviders scope={scope} providers={providers}>
  205. <WithPosition mockPosition={mockPosition} />
  206. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  207. <PdfSynctexControls />
  208. </EditorProviders>
  209. )
  210. cy.waitForCompile()
  211. // mock editor cursor position update
  212. cy.window().then(win => {
  213. win.dispatchEvent(
  214. new CustomEvent('cursor:editor:update', {
  215. detail: { row: 100, column: 10 },
  216. })
  217. )
  218. })
  219. cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
  220. cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
  221. syncCodeResponse => {
  222. cy.findByRole('button', {
  223. name: 'Go to code location in PDF',
  224. })
  225. .should('not.be.disabled')
  226. .click()
  227. cy.findByRole('button', {
  228. name: 'Go to code location in PDF',
  229. })
  230. .should('be.disabled')
  231. .then(() => {
  232. syncCodeResponse.resolve({
  233. body: { pdf: cloneDeep(mockHighlights) },
  234. })
  235. })
  236. cy.wait('@sync-code')
  237. }
  238. )
  239. cy.findByRole('button', {
  240. name: 'Go to code location in PDF',
  241. }).should('not.be.disabled')
  242. // synctex is called locally and the result are broadcast for the detached tab
  243. // NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
  244. cy.get('@postDetachMessage').should('have.been.calledWith', {
  245. role: 'detacher',
  246. event: 'action-setHighlights',
  247. data: { args: [mockHighlights] },
  248. })
  249. })
  250. it('reacts to sync to code action', function () {
  251. cy.interceptCompile()
  252. const scope = mockScope()
  253. const providers = mockProviders()
  254. cy.mount(
  255. <EditorProviders scope={scope} providers={providers}>
  256. <WithPosition mockPosition={mockPosition} />
  257. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  258. <PdfSynctexControls />
  259. </EditorProviders>
  260. )
  261. cy.waitForCompile()
  262. cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf')
  263. .then(syncPdfResponse => {
  264. syncPdfResponse.resolve({
  265. body: { code: [{ file: 'main.tex', line: 100 }] },
  266. })
  267. testDetachChannel.postMessage({
  268. role: 'detached',
  269. event: 'action-sync-to-code',
  270. data: {
  271. args: [mockPosition],
  272. },
  273. })
  274. })
  275. .wait('@sync-pdf')
  276. })
  277. })
  278. describe('with detached role', function () {
  279. beforeEach(function () {
  280. window.metaAttributesCache.set('ol-detachRole', 'detached')
  281. })
  282. it('does not have go to code location button nor arrow icon', function () {
  283. cy.interceptCompile()
  284. const scope = mockScope()
  285. const providers = mockProviders()
  286. cy.mount(
  287. <EditorProviders scope={scope} providers={providers}>
  288. <WithPosition mockPosition={mockPosition} />
  289. <PdfSynctexControls />
  290. </EditorProviders>
  291. )
  292. cy.waitForCompile()
  293. cy.findByRole('button', {
  294. name: 'Go to code location in PDF',
  295. }).should('not.exist')
  296. cy.get('.synctex-control-icon').should('not.exist')
  297. })
  298. it('send go to code line action', function () {
  299. cy.interceptCompile()
  300. const scope = mockScope()
  301. const providers = mockProviders()
  302. cy.mount(
  303. <EditorProviders scope={scope} providers={providers}>
  304. <PdfSynctexControls />
  305. </EditorProviders>
  306. )
  307. cy.waitForCompile().then(() => {
  308. testDetachChannel.postMessage({
  309. role: 'detacher',
  310. event: `state-position`,
  311. data: { value: mockPosition },
  312. })
  313. })
  314. cy.findByRole('button', {
  315. name: /^Go to PDF location in code/,
  316. })
  317. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  318. 'not.be.disabled'
  319. )
  320. cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
  321. cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
  322. // the button is only disabled when the state is updated
  323. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  324. 'not.be.disabled'
  325. )
  326. cy.get('.synctex-spin-icon').should('not.exist')
  327. cy.get('@postDetachMessage').should('have.been.calledWith', {
  328. role: 'detached',
  329. event: 'action-sync-to-code',
  330. data: {
  331. args: [{ visualOffset: 72 }],
  332. },
  333. })
  334. })
  335. it('update inflight state', function () {
  336. cy.interceptCompile()
  337. const scope = mockScope()
  338. const providers = mockProviders()
  339. cy.mount(
  340. <EditorProviders scope={scope} providers={providers}>
  341. <WithPosition mockPosition={mockPosition} />
  342. <PdfSynctexControls />
  343. </EditorProviders>
  344. )
  345. cy.waitForCompile().then(() => {
  346. testDetachChannel.postMessage({
  347. role: 'detacher',
  348. event: `state-position`,
  349. data: { value: mockPosition },
  350. })
  351. })
  352. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  353. 'not.be.disabled'
  354. )
  355. cy.findByTestId('ol-spinner').should('not.exist')
  356. cy.wrap(null).then(() => {
  357. testDetachChannel.postMessage({
  358. role: 'detacher',
  359. event: 'state-sync-to-code-inflight',
  360. data: { value: true },
  361. })
  362. })
  363. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  364. 'be.disabled'
  365. )
  366. cy.findByTestId('ol-spinner').should('have.length', 1)
  367. cy.wrap(null).then(() => {
  368. testDetachChannel.postMessage({
  369. role: 'detacher',
  370. event: 'state-sync-to-code-inflight',
  371. data: { value: false },
  372. })
  373. })
  374. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  375. 'not.be.disabled'
  376. )
  377. cy.get('.synctex-spin-icon').should('not.exist')
  378. })
  379. })
  380. })