ImagePreviewModal.test.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /**
  2. * Tests for ImagePreviewModal (#2976).
  3. *
  4. * jsdom decodes nothing and lays nothing out, so the tests cover the modal's
  5. * own logic — the authenticated fetch, the size guard, zoom input and the
  6. * fullscreen wiring — rather than what the image looks like.
  7. */
  8. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  9. import { fireEvent, render, screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { ImagePreviewModal, IMAGE_PREVIEW_MAX_BYTES } from '../../components/ImagePreviewModal';
  12. vi.mock('../../api/client', () => ({
  13. api: {
  14. getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
  15. },
  16. getAuthToken: () => 'token-123',
  17. }));
  18. const mockOnClose = vi.fn();
  19. function renderModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
  20. return render(
  21. <ImagePreviewModal
  22. libraryFileId={11}
  23. filename="plate.png"
  24. fileSize={2048}
  25. onClose={mockOnClose}
  26. {...props}
  27. />,
  28. );
  29. }
  30. async function renderLoadedModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
  31. const utils = renderModal(props);
  32. const image = (await screen.findByAltText('plate.png')) as HTMLImageElement;
  33. fireEvent.load(image);
  34. return { ...utils, image, content: screen.getByTestId('image-preview-content') };
  35. }
  36. function scaleOf(image: HTMLImageElement): number {
  37. const match = /scale\(([\d.]+)\)/.exec(image.style.transform);
  38. return match ? Number(match[1]) : NaN;
  39. }
  40. describe('ImagePreviewModal', () => {
  41. beforeEach(() => {
  42. vi.clearAllMocks();
  43. vi.stubGlobal(
  44. 'fetch',
  45. vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })),
  46. );
  47. vi.stubGlobal('URL', {
  48. ...URL,
  49. createObjectURL: vi.fn(() => 'blob:image-preview'),
  50. revokeObjectURL: vi.fn(),
  51. });
  52. });
  53. afterEach(() => {
  54. vi.unstubAllGlobals();
  55. });
  56. it('loads the file through the authenticated download URL', async () => {
  57. await renderLoadedModal();
  58. expect(fetch).toHaveBeenCalledWith('http://test/library/files/11/download', {
  59. headers: { Authorization: 'Bearer token-123' },
  60. });
  61. expect(screen.getByAltText('plate.png')).toHaveAttribute('src', 'blob:image-preview');
  62. expect(screen.getByText('plate.png')).toBeInTheDocument();
  63. });
  64. it('refuses a file too large to hold in memory', async () => {
  65. renderModal({ fileSize: IMAGE_PREVIEW_MAX_BYTES + 1 });
  66. expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
  67. expect(fetch).not.toHaveBeenCalled();
  68. });
  69. it('shows a message when the download fails', async () => {
  70. vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
  71. vi.spyOn(console, 'error').mockImplementation(() => {});
  72. renderModal();
  73. expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
  74. });
  75. it('starts fitted to the window and zooms from the buttons', async () => {
  76. const user = userEvent.setup();
  77. const { image } = await renderLoadedModal();
  78. expect(scaleOf(image)).toBe(1);
  79. await user.click(screen.getByRole('button', { name: 'Zoom in' }));
  80. expect(scaleOf(image)).toBeCloseTo(1.25, 5);
  81. await user.click(screen.getByRole('button', { name: 'Zoom out' }));
  82. expect(scaleOf(image)).toBeCloseTo(1, 5);
  83. });
  84. it('zooms with the wheel around the pointer', async () => {
  85. const { image, content } = await renderLoadedModal();
  86. fireEvent.wheel(content, { deltaY: -100, clientX: 40, clientY: 30 });
  87. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
  88. fireEvent.wheel(content, { deltaY: 100, clientX: 40, clientY: 30 });
  89. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1, 5));
  90. });
  91. it('zooms from the keyboard and resets with 0', async () => {
  92. const { image } = await renderLoadedModal();
  93. fireEvent.keyDown(window, { key: '+' });
  94. fireEvent.keyDown(window, { key: '+' });
  95. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.5625, 4));
  96. fireEvent.keyDown(window, { key: '0' });
  97. await waitFor(() => expect(scaleOf(image)).toBe(1));
  98. });
  99. it('stops at the zoom limits', async () => {
  100. const { image } = await renderLoadedModal();
  101. for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: '+' });
  102. await waitFor(() => expect(scaleOf(image)).toBe(8));
  103. for (let i = 0; i < 30; i++) fireEvent.keyDown(window, { key: '-' });
  104. await waitFor(() => expect(scaleOf(image)).toBe(0.4));
  105. });
  106. it('offers grab-to-pan only once the image is bigger than the window', async () => {
  107. const { content } = await renderLoadedModal();
  108. expect(content.className).not.toContain('cursor-grab');
  109. fireEvent.keyDown(window, { key: '+' });
  110. await waitFor(() => expect(content.className).toContain('cursor-grab'));
  111. });
  112. describe('a pointer that never reports its release', () => {
  113. // jsdom has no PointerEvent, and testing-library then falls back to a plain
  114. // Event without pointerId / pointerType / buttons.
  115. class FakePointerEvent extends MouseEvent {
  116. pointerId: number;
  117. pointerType: string;
  118. constructor(type: string, init: PointerEventInit = {}) {
  119. super(type, init);
  120. this.pointerId = init.pointerId ?? 0;
  121. this.pointerType = init.pointerType ?? '';
  122. }
  123. }
  124. /** jsdom lays nothing out, so the pan clamp needs real box sizes. */
  125. function giveBoxes(content: HTMLElement, image: HTMLImageElement) {
  126. Object.defineProperty(content, 'clientWidth', { configurable: true, value: 200 });
  127. Object.defineProperty(content, 'clientHeight', { configurable: true, value: 200 });
  128. Object.defineProperty(image, 'offsetWidth', { configurable: true, value: 400 });
  129. Object.defineProperty(image, 'offsetHeight', { configurable: true, value: 400 });
  130. content.setPointerCapture = vi.fn();
  131. }
  132. function translateOf(image: HTMLImageElement): string {
  133. return /translate\(([^)]*)\)/.exec(image.style.transform)?.[1] ?? '';
  134. }
  135. beforeEach(() => {
  136. Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
  137. });
  138. afterEach(() => {
  139. delete (window as { PointerEvent?: unknown }).PointerEvent;
  140. });
  141. it('drops it when it leaves the image area, instead of panning on hover', async () => {
  142. const { image, content } = await renderLoadedModal();
  143. giveBoxes(content, image);
  144. // Press at zoom 1 — no pointer capture is taken — then release somewhere
  145. // else, so only pointerleave arrives.
  146. fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
  147. fireEvent.pointerLeave(content, { pointerId: 1, pointerType: 'mouse' });
  148. fireEvent.keyDown(window, { key: '+' });
  149. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
  150. fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
  151. expect(translateOf(image)).toBe('0px, 0px');
  152. });
  153. it('drops it on the first buttonless move, so a later touch is not read as a pinch', async () => {
  154. const { image, content } = await renderLoadedModal();
  155. giveBoxes(content, image);
  156. fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
  157. fireEvent.keyDown(window, { key: '+' });
  158. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
  159. // The mouse moves back over the image with nothing held down.
  160. fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
  161. expect(translateOf(image)).toBe('0px, 0px');
  162. // One finger now drags alone: it pans, it does not pinch-zoom.
  163. fireEvent.pointerDown(content, { pointerId: 2, pointerType: 'touch', clientX: 100, clientY: 100 });
  164. fireEvent.pointerMove(content, { pointerId: 2, pointerType: 'touch', clientX: 130, clientY: 100 });
  165. expect(scaleOf(image)).toBeCloseTo(1.25, 5);
  166. expect(translateOf(image)).toBe('30px, 0px');
  167. });
  168. it('still pans while the button is held', async () => {
  169. const { image, content } = await renderLoadedModal();
  170. giveBoxes(content, image);
  171. fireEvent.keyDown(window, { key: '+' });
  172. await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
  173. fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
  174. fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 120, clientY: 110 });
  175. expect(translateOf(image)).toBe('20px, 10px');
  176. });
  177. });
  178. it('closes on Escape', async () => {
  179. await renderLoadedModal();
  180. fireEvent.keyDown(window, { key: 'Escape' });
  181. expect(mockOnClose).toHaveBeenCalledTimes(1);
  182. });
  183. });