pdf-synctex-controls.spec.tsx 11 KB

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