pdf-synctex-controls.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
  2. import sysendTestHelper from '../../helpers/sysend'
  3. import { cloneDeep } from 'lodash'
  4. import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
  5. import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
  6. import { useEffect } from 'react'
  7. import { EditorProviders } from '../../helpers/editor-providers'
  8. import { mockScope } from './scope'
  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. sysendTestHelper.receiveMessage({
  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. const interceptSyncCodeAsync = () => {
  67. const output: { resolve: () => void } = {
  68. resolve: () => {
  69. // do nothing
  70. },
  71. }
  72. cy.intercept('/project/*/sync/code?*', req => {
  73. return new Promise(resolve => {
  74. output.resolve = () => {
  75. req.reply({
  76. body: { pdf: cloneDeep(mockHighlights) },
  77. })
  78. resolve()
  79. }
  80. })
  81. }).as('sync-code')
  82. return output
  83. }
  84. const interceptSyncPdfAsync = () => {
  85. const output: { resolve: () => void } = {
  86. resolve: () => {
  87. // do nothing
  88. },
  89. }
  90. cy.intercept('/project/*/sync/pdf?*', req => {
  91. return new Promise(resolve => {
  92. output.resolve = () => {
  93. req.reply({
  94. body: { code: [{ file: 'main.tex', line: 100 }] },
  95. delay: 1,
  96. })
  97. resolve()
  98. }
  99. })
  100. }).as('sync-pdf')
  101. return output
  102. }
  103. const interceptSyncPdf = () => {
  104. cy.intercept('/project/*/sync/pdf?*', req => {
  105. req.reply({
  106. body: { code: [{ file: 'main.tex', line: 100 }] },
  107. })
  108. }).as('sync-pdf')
  109. }
  110. describe('<PdfSynctexControls/>', function () {
  111. beforeEach(function () {
  112. window.metaAttributesCache = new Map()
  113. cy.interceptCompile()
  114. cy.interceptEvents()
  115. })
  116. afterEach(function () {
  117. window.metaAttributesCache = new Map()
  118. })
  119. it('handles clicks on sync buttons', function () {
  120. const scope = mockScope()
  121. cy.mount(
  122. <EditorProviders scope={scope}>
  123. <WithPosition mockPosition={mockPosition} />
  124. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  125. <PdfSynctexControls />
  126. </EditorProviders>
  127. )
  128. cy.get('.synctex-control-icon').should('have.length', 2)
  129. // mock editor cursor position update
  130. cy.window().then(win => {
  131. win.dispatchEvent(
  132. new CustomEvent('cursor:editor:update', {
  133. detail: { row: 100, column: 10 },
  134. })
  135. )
  136. })
  137. cy.wait('@compile').then(() => {
  138. setDetachedPosition(mockPosition)
  139. })
  140. const syncCode = interceptSyncCodeAsync()
  141. cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
  142. cy.findByRole('button', { name: 'Go to code location in PDF' })
  143. .should('be.disabled')
  144. .then(() => {
  145. syncCode.resolve()
  146. })
  147. cy.wait('@sync-code')
  148. const syncPdf = interceptSyncPdfAsync()
  149. cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
  150. cy.findByRole('button', { name: /^Go to PDF location in code/ })
  151. .should('be.disabled')
  152. .then(() => {
  153. syncPdf.resolve()
  154. })
  155. cy.wait('@sync-pdf')
  156. })
  157. it('disables button when multiple entities are selected', function () {
  158. const scope = mockScope()
  159. cy.mount(
  160. <EditorProviders scope={scope}>
  161. <WithPosition mockPosition={mockPosition} />
  162. <WithSelectedEntities
  163. mockSelectedEntities={[{ type: 'doc' }, { type: 'doc' }]}
  164. />
  165. <PdfSynctexControls />
  166. </EditorProviders>
  167. )
  168. cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
  169. 'be.disabled'
  170. )
  171. })
  172. it('disables button when a file is selected', function () {
  173. const scope = mockScope()
  174. cy.mount(
  175. <EditorProviders scope={scope}>
  176. <WithPosition mockPosition={mockPosition} />
  177. <WithSelectedEntities mockSelectedEntities={[{ type: 'file' }]} />
  178. <PdfSynctexControls />
  179. </EditorProviders>
  180. )
  181. cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
  182. 'be.disabled'
  183. )
  184. })
  185. describe('with detacher role', function () {
  186. beforeEach(function () {
  187. window.metaAttributesCache.set('ol-detachRole', 'detacher')
  188. })
  189. it('does not have go to PDF location button nor arrow icon', function () {
  190. const scope = mockScope()
  191. cy.mount(
  192. <EditorProviders scope={scope}>
  193. <WithPosition mockPosition={mockPosition} />
  194. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  195. <PdfSynctexControls />
  196. </EditorProviders>
  197. )
  198. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  199. 'not.exist'
  200. )
  201. cy.get('.synctex-control-icon').should('not.exist')
  202. })
  203. it('send set highlights action', function () {
  204. const scope = mockScope()
  205. cy.mount(
  206. <EditorProviders scope={scope}>
  207. <WithPosition mockPosition={mockPosition} />
  208. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  209. <PdfSynctexControls />
  210. </EditorProviders>
  211. ).then(() => {
  212. sysendTestHelper.resetHistory()
  213. })
  214. cy.wait('@compile')
  215. // mock editor cursor position update
  216. cy.window().then(win => {
  217. win.dispatchEvent(
  218. new CustomEvent('cursor:editor:update', {
  219. detail: { row: 100, column: 10 },
  220. })
  221. )
  222. })
  223. const syncing = interceptSyncCodeAsync()
  224. cy.findByRole('button', {
  225. name: 'Go to code location in PDF',
  226. })
  227. .should('not.be.disabled')
  228. .click()
  229. cy.findByRole('button', {
  230. name: 'Go to code location in PDF',
  231. })
  232. .should('be.disabled')
  233. .then(() => {
  234. syncing.resolve()
  235. })
  236. cy.wait('@sync-code')
  237. cy.findByRole('button', {
  238. name: 'Go to code location in PDF',
  239. }).should(() => {
  240. const message = sysendTestHelper.getMessageWithEvent(
  241. 'action-setHighlights'
  242. )
  243. // synctex is called locally and the result are broadcast for the detached tab
  244. // NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
  245. expect(message).to.deep.equal({
  246. role: 'detacher',
  247. event: 'action-setHighlights',
  248. data: { args: [mockHighlights] },
  249. })
  250. })
  251. })
  252. it('reacts to sync to code action', function () {
  253. interceptSyncPdf()
  254. const scope = mockScope()
  255. cy.mount(
  256. <EditorProviders scope={scope}>
  257. <WithPosition mockPosition={mockPosition} />
  258. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  259. <PdfSynctexControls />
  260. </EditorProviders>
  261. ).then(() => {
  262. sysendTestHelper.receiveMessage({
  263. role: 'detached',
  264. event: 'action-sync-to-code',
  265. data: {
  266. args: [mockPosition],
  267. },
  268. })
  269. })
  270. cy.wait('@sync-pdf')
  271. })
  272. })
  273. describe('with detached role', function () {
  274. beforeEach(function () {
  275. window.metaAttributesCache.set('ol-detachRole', 'detached')
  276. })
  277. it('does not have go to code location button nor arrow icon', function () {
  278. const scope = mockScope()
  279. cy.mount(
  280. <EditorProviders scope={scope}>
  281. <WithPosition mockPosition={mockPosition} />
  282. <PdfSynctexControls />
  283. </EditorProviders>
  284. )
  285. cy.findByRole('button', {
  286. name: 'Go to code location in PDF',
  287. }).should('not.exist')
  288. cy.get('.synctex-control-icon').should('not.exist')
  289. })
  290. it('send go to code line action', function () {
  291. const scope = mockScope()
  292. cy.mount(
  293. <EditorProviders scope={scope}>
  294. <PdfSynctexControls />
  295. </EditorProviders>
  296. )
  297. cy.wait('@compile').then(() => {
  298. sysendTestHelper.receiveMessage({
  299. role: 'detacher',
  300. event: `state-position`,
  301. data: { value: mockPosition },
  302. })
  303. })
  304. cy.findByRole('button', {
  305. name: /^Go to PDF location in code/,
  306. })
  307. cy.findByRole('button', { name: /^Go to PDF location in code/ })
  308. .should('not.be.disabled')
  309. .then(() => {
  310. sysendTestHelper.resetHistory()
  311. })
  312. cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
  313. // the button is only disabled when the state is updated via sysend
  314. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  315. 'not.be.disabled'
  316. )
  317. cy.get('.synctex-spin-icon').should('not.exist')
  318. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  319. () => {
  320. const message = sysendTestHelper.getMessageWithEvent(
  321. 'action-sync-to-code'
  322. )
  323. expect(message).to.deep.equal({
  324. role: 'detached',
  325. event: 'action-sync-to-code',
  326. data: {
  327. args: [mockPosition, 72],
  328. },
  329. })
  330. }
  331. )
  332. })
  333. it('update inflight state', function () {
  334. const scope = mockScope()
  335. cy.mount(
  336. <EditorProviders scope={scope}>
  337. <WithPosition mockPosition={mockPosition} />
  338. <PdfSynctexControls />
  339. </EditorProviders>
  340. ).then(() => {
  341. sysendTestHelper.receiveMessage({
  342. role: 'detacher',
  343. event: `state-position`,
  344. data: { value: mockPosition },
  345. })
  346. })
  347. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  348. 'not.be.disabled'
  349. )
  350. cy.get('.synctex-spin-icon')
  351. .should('not.exist')
  352. .then(() => {
  353. sysendTestHelper.receiveMessage({
  354. role: 'detacher',
  355. event: 'state-sync-to-code-inflight',
  356. data: { value: true },
  357. })
  358. })
  359. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  360. 'be.disabled'
  361. )
  362. cy.get('.synctex-spin-icon')
  363. .should('have.length', 1)
  364. .then(() => {
  365. sysendTestHelper.receiveMessage({
  366. role: 'detacher',
  367. event: 'state-sync-to-code-inflight',
  368. data: { value: false },
  369. })
  370. })
  371. cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
  372. 'not.be.disabled'
  373. )
  374. cy.get('.synctex-spin-icon').should('not.exist')
  375. })
  376. })
  377. })