FileManagerPreviewOpening.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. /**
  2. * How a preview is opened from the File Manager (#2976): double-click in the
  3. * grid and the list, the toolbar's Preview button, and the per-file menu.
  4. *
  5. * The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
  6. * have their own tests and none of them render in jsdom.
  7. */
  8. import { describe, it, expect, beforeEach, vi } from 'vitest';
  9. import { fireEvent, screen, waitFor, within } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { http, HttpResponse } from 'msw';
  12. import { render } from '../utils';
  13. import { FileManagerPage } from '../../pages/FileManagerPage';
  14. import { server } from '../mocks/server';
  15. const mockNavigate = vi.fn();
  16. vi.mock('react-router-dom', async (importOriginal) => ({
  17. ...(await importOriginal<typeof import('react-router-dom')>()),
  18. useNavigate: () => mockNavigate,
  19. }));
  20. vi.mock('../../components/ModelViewerModal', () => ({
  21. ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
  22. }));
  23. vi.mock('../../components/PdfPreviewModal', () => ({
  24. PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
  25. }));
  26. vi.mock('../../components/SpreadsheetPreviewModal', () => ({
  27. SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
  28. <div data-testid="sheet-preview-modal">{filename}</div>
  29. ),
  30. }));
  31. vi.mock('../../components/ImagePreviewModal', () => ({
  32. ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
  33. }));
  34. vi.mock('../../components/LibraryFileDetailsModal', () => ({
  35. LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
  36. }));
  37. function libraryFile(overrides: Record<string, unknown>) {
  38. return {
  39. file_path: '/library/file',
  40. file_size: 4096,
  41. folder_id: null,
  42. thumbnail_path: null,
  43. print_name: null,
  44. print_time_seconds: null,
  45. print_count: 0,
  46. duplicate_count: 0,
  47. created_at: '2026-01-01T00:00:00Z',
  48. ...overrides,
  49. };
  50. }
  51. const mockFiles = [
  52. libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
  53. libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
  54. libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
  55. libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
  56. libraryFile({
  57. id: 5,
  58. filename: 'photo.png',
  59. file_type: 'png',
  60. tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
  61. has_notes: true,
  62. }),
  63. libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
  64. libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
  65. ];
  66. function card(name: string): HTMLElement {
  67. return screen.getByText(name).closest('div.group') as HTMLElement;
  68. }
  69. function row(name: string): HTMLElement {
  70. return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
  71. }
  72. /**
  73. * The preview modals load lazily, so one opened by mistake renders a moment
  74. * after the event that opened it: a few hundred milliseconds for the first
  75. * one in a run. Absence only counts once it has had findBy's full second to
  76. * appear; checking straight away passed whenever no earlier test had loaded
  77. * that modal yet.
  78. */
  79. async function expectNoPreviewOpened() {
  80. await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
  81. }
  82. describe('FileManagerPage preview opening', () => {
  83. beforeEach(() => {
  84. mockNavigate.mockClear();
  85. // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
  86. // the view mode is programmed rather than written.
  87. (localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
  88. server.use(
  89. http.get('/api/v1/library/folders', () => HttpResponse.json([])),
  90. http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
  91. http.get('/api/v1/library/stats', () =>
  92. HttpResponse.json({
  93. total_files: mockFiles.length,
  94. total_folders: 0,
  95. total_size_bytes: 1024,
  96. disk_free_bytes: 1024 * 1024,
  97. disk_total_bytes: 2048 * 1024,
  98. }),
  99. ),
  100. http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
  101. http.get('/api/v1/projects/', () => HttpResponse.json([])),
  102. http.get('/api/v1/archives/', () => HttpResponse.json([])),
  103. );
  104. });
  105. describe('double-click in the grid', () => {
  106. it('opens the image preview for a PNG and leaves the selection alone', async () => {
  107. const user = userEvent.setup();
  108. render(<FileManagerPage />);
  109. await screen.findByText('photo.png');
  110. await user.dblClick(card('photo.png'));
  111. expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
  112. // The two clicks of a double-click toggle the selection twice.
  113. expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
  114. });
  115. it('opens the 3D viewer for an STL', async () => {
  116. const user = userEvent.setup();
  117. render(<FileManagerPage />);
  118. await screen.findByText('bracket.stl');
  119. await user.dblClick(card('bracket.stl'));
  120. expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
  121. });
  122. it('sends a sliced file to the gcode viewer route', async () => {
  123. const user = userEvent.setup();
  124. render(<FileManagerPage />);
  125. await screen.findByText('benchy.gcode.3mf');
  126. await user.dblClick(card('benchy.gcode.3mf'));
  127. expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
  128. });
  129. it('does nothing for a file with no preview', async () => {
  130. const user = userEvent.setup();
  131. render(<FileManagerPage />);
  132. await screen.findByText('notes.md');
  133. await user.dblClick(card('notes.md'));
  134. await expectNoPreviewOpened();
  135. expect(mockNavigate).not.toHaveBeenCalled();
  136. });
  137. // The card's own controls are not "the row": stopping their click is not
  138. // enough, because dblclick is a separate native event (#2976).
  139. it('ignores a double-click on the card menu button', async () => {
  140. const user = userEvent.setup();
  141. render(<FileManagerPage />);
  142. await screen.findByText('photo.png');
  143. const imageCard = card('photo.png');
  144. const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  145. await user.dblClick(kebab);
  146. await expectNoPreviewOpened();
  147. });
  148. // The chip's own click toggles the tag filter and re-renders the list, so
  149. // the dblclick is fired directly: what is under test is whether it bubbles
  150. // to the card, not what the two clicks before it did.
  151. it('ignores a double-click on a tag chip', async () => {
  152. render(<FileManagerPage />);
  153. await screen.findByText('photo.png');
  154. fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
  155. await expectNoPreviewOpened();
  156. });
  157. // The notes and photos indicators open the file's details; a double-click
  158. // on one must not open the preview on top of them.
  159. it('ignores a double-click on the notes indicator', async () => {
  160. const user = userEvent.setup();
  161. render(<FileManagerPage />);
  162. await screen.findByText('photo.png');
  163. await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
  164. expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
  165. await expectNoPreviewOpened();
  166. });
  167. });
  168. describe('the list view', () => {
  169. beforeEach(() => {
  170. (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
  171. key === 'library-view-mode' ? 'list' : null,
  172. );
  173. });
  174. it('opens the document preview for a PDF', async () => {
  175. const user = userEvent.setup();
  176. render(<FileManagerPage />);
  177. await screen.findByText('drawing.pdf');
  178. await user.dblClick(row('drawing.pdf'));
  179. expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
  180. });
  181. it('opens the spreadsheet preview for a CSV', async () => {
  182. const user = userEvent.setup();
  183. render(<FileManagerPage />);
  184. await screen.findByText('parts.csv');
  185. await user.dblClick(row('parts.csv'));
  186. expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
  187. });
  188. it('offers an image file the same action-strip preview button as a document', async () => {
  189. const user = userEvent.setup();
  190. render(<FileManagerPage />);
  191. await screen.findByText('photo.png');
  192. await user.click(within(row('photo.png')).getByTitle('Preview'));
  193. expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
  194. });
  195. it('ignores a double-click on the row action strip', async () => {
  196. const user = userEvent.setup();
  197. render(<FileManagerPage />);
  198. await screen.findByText('photo.png');
  199. // An impatient double-tap on Rename must not also open the preview.
  200. await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
  201. await expectNoPreviewOpened();
  202. });
  203. it('ignores a double-click on the row tag cell', async () => {
  204. render(<FileManagerPage />);
  205. await screen.findByText('photo.png');
  206. fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
  207. await expectNoPreviewOpened();
  208. });
  209. });
  210. describe('the toolbar Preview button', () => {
  211. it('appears for a single previewable selection and opens the preview', async () => {
  212. const user = userEvent.setup();
  213. render(<FileManagerPage />);
  214. await screen.findByText('photo.png');
  215. await user.click(card('photo.png'));
  216. const preview = await screen.findByRole('button', { name: 'Preview' });
  217. await user.click(preview);
  218. expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
  219. });
  220. it('stays away for a multi-file selection', async () => {
  221. const user = userEvent.setup();
  222. render(<FileManagerPage />);
  223. await screen.findByText('photo.png');
  224. await user.click(card('photo.png'));
  225. expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
  226. await user.click(card('drawing.pdf'));
  227. await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
  228. });
  229. it('stays away for a file with no preview', async () => {
  230. const user = userEvent.setup();
  231. render(<FileManagerPage />);
  232. await screen.findByText('notes.md');
  233. await user.click(card('notes.md'));
  234. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  235. expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
  236. });
  237. });
  238. // The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
  239. // so the preview is not offered rather than downloading 50 MB to fail (#2976).
  240. describe('a TIFF file', () => {
  241. it('gets no Preview entry in the card menu and no toolbar button', async () => {
  242. const user = userEvent.setup();
  243. render(<FileManagerPage />);
  244. await screen.findByText('scan.tif');
  245. await user.click(card('scan.tif'));
  246. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  247. expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
  248. const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  249. await user.click(kebab);
  250. expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
  251. });
  252. it('does nothing on double-click', async () => {
  253. const user = userEvent.setup();
  254. render(<FileManagerPage />);
  255. await screen.findByText('scan.tif');
  256. await user.dblClick(card('scan.tif'));
  257. await expectNoPreviewOpened();
  258. expect(mockNavigate).not.toHaveBeenCalled();
  259. });
  260. });
  261. it('offers an image file a Preview entry in the card menu', async () => {
  262. const user = userEvent.setup();
  263. render(<FileManagerPage />);
  264. await screen.findByText('photo.png');
  265. const imageCard = card('photo.png');
  266. const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  267. await user.click(kebab);
  268. await user.click(within(imageCard).getByText('Preview'));
  269. expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
  270. });
  271. });