PdfPreviewModal.test.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. /**
  2. * Tests for PdfPreviewModal (#2976).
  3. *
  4. * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
  5. * mocked at the module boundary; the tests cover the modal's own logic —
  6. * loading, page navigation, error/size fallbacks, zoom input and fullscreen.
  7. */
  8. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  9. import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { PdfPreviewModal } from '../../components/PdfPreviewModal';
  12. const pdfjsMocks = vi.hoisted(() => {
  13. const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
  14. const defaultViewport = ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale });
  15. const getViewport = vi.fn(defaultViewport);
  16. const getPage = vi.fn(async () => ({ getViewport, render }));
  17. const getDocument = vi.fn(() => ({
  18. promise: Promise.resolve({ numPages: 3, getPage }),
  19. destroy: vi.fn(),
  20. }));
  21. return { render, defaultViewport, getViewport, getPage, getDocument };
  22. });
  23. vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
  24. GlobalWorkerOptions: { workerSrc: '' },
  25. getDocument: pdfjsMocks.getDocument,
  26. }));
  27. vi.mock('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url', () => ({ default: 'pdf.worker.min.mjs' }));
  28. vi.mock('../../api/client', () => ({
  29. api: {
  30. getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
  31. },
  32. getAuthToken: () => null,
  33. }));
  34. const mockOnClose = vi.fn();
  35. function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
  36. return render(
  37. <PdfPreviewModal
  38. libraryFileId={7}
  39. filename="drawing.pdf"
  40. fileSize={1024}
  41. onClose={mockOnClose}
  42. {...props}
  43. />,
  44. );
  45. }
  46. // jsdom lays nothing out, so the fit scale bottoms out at the 0.1 floor and
  47. // the 600pt page is shown 60px wide at zoom 1.
  48. const CSS_WIDTH_AT_ZOOM_1 = 60;
  49. async function renderLoadedModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
  50. const utils = renderModal(props);
  51. await screen.findByText('Page 1 of 3');
  52. await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
  53. const page = screen.getByTestId('pdf-preview-page');
  54. const canvas = page.querySelector('canvas') as HTMLCanvasElement;
  55. await waitFor(() => expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`));
  56. return { ...utils, page, canvas };
  57. }
  58. function lastRenderScale(): number {
  59. const calls = pdfjsMocks.getViewport.mock.calls;
  60. return calls[calls.length - 1][0].scale;
  61. }
  62. // A wheel event with ctrlKey, as Ctrl+wheel and a trackpad pinch both arrive.
  63. function wheelWithCtrl(target: Element, deltaY: number) {
  64. return fireEvent.wheel(target, { deltaY, ctrlKey: true, clientX: 30, clientY: 40 });
  65. }
  66. describe('PdfPreviewModal', () => {
  67. beforeEach(() => {
  68. vi.clearAllMocks();
  69. // clearAllMocks keeps implementations, but a test that swaps the page
  70. // geometry would otherwise leak it into every test after it.
  71. pdfjsMocks.getViewport.mockImplementation(pdfjsMocks.defaultViewport);
  72. vi.stubGlobal(
  73. 'fetch',
  74. vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
  75. );
  76. // The setup-file stub is wiped by unstubAllGlobals below; keep one here.
  77. vi.stubGlobal(
  78. 'ResizeObserver',
  79. class {
  80. observe() {}
  81. unobserve() {}
  82. disconnect() {}
  83. },
  84. );
  85. });
  86. afterEach(() => {
  87. vi.unstubAllGlobals();
  88. });
  89. it('shows the page indicator once the document loads', async () => {
  90. renderModal();
  91. expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
  92. // The indicator appears when the document loads; the page is drawn a step
  93. // later, after getPage() resolves in an effect, so wait for it.
  94. await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
  95. });
  96. it('navigates between pages', async () => {
  97. const user = userEvent.setup();
  98. renderModal();
  99. await screen.findByText('Page 1 of 3');
  100. await user.click(screen.getByRole('button', { name: 'Next page' }));
  101. expect(await screen.findByText('Page 2 of 3')).toBeInTheDocument();
  102. expect(pdfjsMocks.getPage).toHaveBeenLastCalledWith(2);
  103. await user.click(screen.getByRole('button', { name: 'Previous page' }));
  104. expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
  105. });
  106. it('shows an error message when the document cannot be parsed', async () => {
  107. pdfjsMocks.getDocument.mockReturnValueOnce({ promise: Promise.reject(new Error('bad pdf')), destroy: vi.fn() } as never);
  108. renderModal();
  109. expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
  110. });
  111. it('points pdf.js at the resources it fetches at runtime', async () => {
  112. renderModal();
  113. await screen.findByText('Page 1 of 3');
  114. // Unset, CJK text has no CMaps, non-embedded fonts no font data, and the
  115. // JPEG2000/JBIG2/ICC decoders no wasm — all of which fail silently.
  116. expect(pdfjsMocks.getDocument).toHaveBeenCalledWith(
  117. expect.objectContaining({
  118. cMapUrl: '/assets/pdfjs/cmaps/',
  119. iccUrl: '/assets/pdfjs/iccs/',
  120. standardFontDataUrl: '/assets/pdfjs/standard_fonts/',
  121. wasmUrl: '/assets/pdfjs/wasm/',
  122. }),
  123. );
  124. });
  125. it('destroys a loading task that resolved after the modal closed', async () => {
  126. // Closing during the fetch/import window used to leave cleanup holding a
  127. // null task, and the pdf.js worker it later started ran on forever.
  128. let deliver: (response: Response) => void = () => {};
  129. vi.stubGlobal(
  130. 'fetch',
  131. vi.fn(() => new Promise<Response>((resolve) => (deliver = resolve))),
  132. );
  133. const destroy = vi.fn();
  134. pdfjsMocks.getDocument.mockReturnValueOnce({ promise: new Promise(() => {}), destroy } as never);
  135. const { unmount } = renderModal();
  136. unmount();
  137. deliver(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
  138. await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1));
  139. });
  140. it('refuses oversized files without fetching them', async () => {
  141. const fetchSpy = vi.fn();
  142. vi.stubGlobal('fetch', fetchSpy);
  143. renderModal({ fileSize: 500 * 1024 * 1024 });
  144. expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
  145. expect(fetchSpy).not.toHaveBeenCalled();
  146. });
  147. describe('zoom', () => {
  148. it('zooms on Ctrl+wheel and keeps the browser from zooming the page', async () => {
  149. const { page, canvas } = await renderLoadedModal();
  150. const scaleBefore = lastRenderScale();
  151. const notPrevented = wheelWithCtrl(page, -100);
  152. expect(notPrevented).toBe(false);
  153. // Immediate feedback: the raster is scaled by CSS by exactly one step.
  154. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
  155. // Then the page is re-rasterised at the new scale once the wheel settles.
  156. await waitFor(() => expect(lastRenderScale()).toBeCloseTo(scaleBefore * 1.25, 5));
  157. expect(pdfjsMocks.render).toHaveBeenCalledTimes(2);
  158. });
  159. it('zooms out on Ctrl+wheel down', async () => {
  160. const { page, canvas } = await renderLoadedModal();
  161. wheelWithCtrl(page, 100);
  162. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
  163. });
  164. it('turns a pinch (many small ctrlKey deltas) into a smooth ramp', async () => {
  165. const { page, canvas } = await renderLoadedModal();
  166. for (let i = 0; i < 10; i++) wheelWithCtrl(page, -3);
  167. const width = parseFloat(canvas.style.width);
  168. expect(width).toBeGreaterThan(CSS_WIDTH_AT_ZOOM_1);
  169. // Ten pixels of pinch is one wheel notch (30px clamp → one step), not ten.
  170. expect(width).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
  171. });
  172. it('clamps to the zoom range', async () => {
  173. const { page, canvas } = await renderLoadedModal();
  174. for (let i = 0; i < 40; i++) wheelWithCtrl(page, -100);
  175. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 4, 5);
  176. for (let i = 0; i < 40; i++) wheelWithCtrl(page, 100);
  177. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 0.4, 5);
  178. });
  179. it('zooms on a plain wheel while the page fits the viewport', async () => {
  180. const { page, canvas } = await renderLoadedModal();
  181. // jsdom reports every scroll metric as 0: nothing to scroll.
  182. const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
  183. expect(notPrevented).toBe(false);
  184. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
  185. });
  186. it('lets a plain wheel scroll once the page overflows the viewport', async () => {
  187. const { page, canvas } = await renderLoadedModal();
  188. Object.defineProperty(page, 'scrollHeight', { configurable: true, value: 2000 });
  189. Object.defineProperty(page, 'clientHeight', { configurable: true, value: 500 });
  190. const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
  191. expect(notPrevented).toBe(true);
  192. expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
  193. });
  194. it('zooms with the keyboard: + and - step, 0 resets', async () => {
  195. const { canvas } = await renderLoadedModal();
  196. fireEvent.keyDown(window, { key: '+' });
  197. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
  198. fireEvent.keyDown(window, { key: '-' });
  199. fireEvent.keyDown(window, { key: '-' });
  200. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
  201. fireEvent.keyDown(window, { key: '0' });
  202. expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
  203. });
  204. it('leaves Ctrl+plus / Ctrl+0 to the browser', async () => {
  205. const { canvas } = await renderLoadedModal();
  206. fireEvent.keyDown(window, { key: '+', ctrlKey: true });
  207. fireEvent.keyDown(window, { key: '0', metaKey: true });
  208. expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
  209. });
  210. it('pinch-zooms with two touch pointers', async () => {
  211. // jsdom has no PointerEvent, and testing-library then falls back to a
  212. // plain Event without pointerId / pointerType.
  213. class FakePointerEvent extends MouseEvent {
  214. pointerId: number;
  215. pointerType: string;
  216. constructor(type: string, init: PointerEventInit = {}) {
  217. super(type, init);
  218. this.pointerId = init.pointerId ?? 0;
  219. this.pointerType = init.pointerType ?? '';
  220. }
  221. }
  222. Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
  223. const { page, canvas } = await renderLoadedModal();
  224. fireEvent.pointerDown(page, { pointerId: 1, pointerType: 'touch', clientX: 100, clientY: 100 });
  225. fireEvent.pointerDown(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
  226. // First move sets the reference distance, the second one spreads it.
  227. fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
  228. fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 300, clientY: 100 });
  229. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 2, 5);
  230. fireEvent.pointerUp(page, { pointerId: 2, pointerType: 'touch' });
  231. fireEvent.pointerUp(page, { pointerId: 1, pointerType: 'touch' });
  232. delete (window as { PointerEvent?: unknown }).PointerEvent;
  233. });
  234. it('still offers the zoom buttons', async () => {
  235. const user = userEvent.setup();
  236. const { canvas } = await renderLoadedModal();
  237. await user.click(screen.getByRole('button', { name: 'Zoom in' }));
  238. expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
  239. await user.click(screen.getByRole('button', { name: 'Zoom out' }));
  240. expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
  241. });
  242. });
  243. describe('fullscreen', () => {
  244. const requestFullscreen = vi.fn();
  245. const exitFullscreen = vi.fn();
  246. beforeEach(() => {
  247. requestFullscreen.mockReset().mockResolvedValue(undefined);
  248. exitFullscreen.mockReset().mockResolvedValue(undefined);
  249. Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
  250. Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
  251. Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
  252. Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
  253. });
  254. afterEach(() => {
  255. delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
  256. delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
  257. delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
  258. delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
  259. });
  260. // The browser flips fullscreenElement and fires fullscreenchange; the
  261. // mocks do neither, so the test plays the browser's part.
  262. function enterFullscreen(panel: Element) {
  263. (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
  264. act(() => {
  265. document.dispatchEvent(new Event('fullscreenchange'));
  266. });
  267. }
  268. it('double-click on the page toggles fullscreen', async () => {
  269. const { page } = await renderLoadedModal();
  270. const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
  271. fireEvent.doubleClick(page);
  272. expect(requestFullscreen).toHaveBeenCalledTimes(1);
  273. expect(requestFullscreen.mock.instances[0]).toBe(panel);
  274. enterFullscreen(panel);
  275. expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
  276. fireEvent.doubleClick(page);
  277. expect(exitFullscreen).toHaveBeenCalledTimes(1);
  278. });
  279. it('offers a fullscreen button in the header', async () => {
  280. const user = userEvent.setup();
  281. await renderLoadedModal();
  282. await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
  283. expect(requestFullscreen).toHaveBeenCalledTimes(1);
  284. });
  285. it('re-syncs when the browser leaves fullscreen on Esc', async () => {
  286. const { page } = await renderLoadedModal();
  287. const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
  288. fireEvent.doubleClick(page);
  289. enterFullscreen(panel);
  290. expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
  291. // Esc while fullscreen is the browser's: the modal must not close.
  292. fireEvent.keyDown(window, { key: 'Escape' });
  293. expect(mockOnClose).not.toHaveBeenCalled();
  294. enterFullscreen(null as unknown as Element);
  295. expect(screen.getByRole('button', { name: 'Fullscreen' })).toBeInTheDocument();
  296. fireEvent.keyDown(window, { key: 'Escape' });
  297. expect(mockOnClose).toHaveBeenCalledTimes(1);
  298. });
  299. });
  300. describe('grid thumbnail snapshot', () => {
  301. // jsdom has no 2D backend: the snapshot helper draws into an offscreen
  302. // canvas and hands the result to toBlob, so both have to be stood in for.
  303. function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
  304. const context = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() };
  305. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
  306. context as unknown as CanvasRenderingContext2D,
  307. );
  308. vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
  309. return context;
  310. }
  311. afterEach(() => {
  312. vi.restoreAllMocks();
  313. });
  314. it('hands the first page to onSnapshot as a PNG', async () => {
  315. stubCanvas2d();
  316. const onSnapshot = vi.fn();
  317. await renderLoadedModal({ onSnapshot });
  318. await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
  319. const blob = onSnapshot.mock.calls[0][0] as Blob;
  320. expect(blob.type).toBe('image/png');
  321. });
  322. it('sends it once, not on every re-render of page 1', async () => {
  323. stubCanvas2d();
  324. const user = userEvent.setup();
  325. const onSnapshot = vi.fn();
  326. await renderLoadedModal({ onSnapshot });
  327. await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
  328. await user.click(screen.getByRole('button', { name: 'Next page' }));
  329. await screen.findByText('Page 2 of 3');
  330. await user.click(screen.getByRole('button', { name: 'Previous page' }));
  331. await screen.findByText('Page 1 of 3');
  332. expect(onSnapshot).toHaveBeenCalledTimes(1);
  333. });
  334. it('stays quiet when the canvas yields no blob', async () => {
  335. stubCanvas2d(null);
  336. const onSnapshot = vi.fn();
  337. await renderLoadedModal({ onSnapshot });
  338. await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
  339. expect(onSnapshot).not.toHaveBeenCalled();
  340. });
  341. });
  342. it('keeps the raster inside the canvas area iOS Safari will back', async () => {
  343. // Past roughly 16.7M pixels iOS Safari hands back a blank canvas instead
  344. // of failing, so resolution is what gives way, not the page.
  345. vi.stubGlobal('devicePixelRatio', 2);
  346. pdfjsMocks.getViewport.mockImplementation(({ scale }: { scale: number }) => ({
  347. width: 30000 * scale,
  348. height: 30000 * scale,
  349. }));
  350. renderModal();
  351. await screen.findByText('Page 1 of 3');
  352. await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
  353. // Unclamped this would be 0.1 (fit floor) x 2 (dpr) = 6000 x 6000 px.
  354. const scale = lastRenderScale();
  355. expect(scale).toBeLessThan(0.2);
  356. expect(30000 * scale * (30000 * scale)).toBeLessThanOrEqual(16 * 1024 * 1024);
  357. });
  358. });