| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- /**
- * How a preview is opened from the File Manager (#2976): double-click in the
- * grid and the list, the toolbar's Preview button, and the per-file menu.
- *
- * The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
- * have their own tests and none of them render in jsdom.
- */
- import { describe, it, expect, beforeEach, vi } from 'vitest';
- import { fireEvent, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { http, HttpResponse } from 'msw';
- import { render } from '../utils';
- import { FileManagerPage } from '../../pages/FileManagerPage';
- import { server } from '../mocks/server';
- const mockNavigate = vi.fn();
- vi.mock('react-router-dom', async (importOriginal) => ({
- ...(await importOriginal<typeof import('react-router-dom')>()),
- useNavigate: () => mockNavigate,
- }));
- vi.mock('../../components/ModelViewerModal', () => ({
- ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
- }));
- vi.mock('../../components/PdfPreviewModal', () => ({
- PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
- }));
- vi.mock('../../components/SpreadsheetPreviewModal', () => ({
- SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
- <div data-testid="sheet-preview-modal">{filename}</div>
- ),
- }));
- vi.mock('../../components/ImagePreviewModal', () => ({
- ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
- }));
- vi.mock('../../components/LibraryFileDetailsModal', () => ({
- LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
- }));
- function libraryFile(overrides: Record<string, unknown>) {
- return {
- file_path: '/library/file',
- file_size: 4096,
- folder_id: null,
- thumbnail_path: null,
- print_name: null,
- print_time_seconds: null,
- print_count: 0,
- duplicate_count: 0,
- created_at: '2026-01-01T00:00:00Z',
- ...overrides,
- };
- }
- const mockFiles = [
- libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
- libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
- libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
- libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
- libraryFile({
- id: 5,
- filename: 'photo.png',
- file_type: 'png',
- tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
- has_notes: true,
- }),
- libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
- libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
- ];
- function card(name: string): HTMLElement {
- return screen.getByText(name).closest('div.group') as HTMLElement;
- }
- function row(name: string): HTMLElement {
- return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
- }
- /**
- * The preview modals load lazily, so one opened by mistake renders a moment
- * after the event that opened it: a few hundred milliseconds for the first
- * one in a run. Absence only counts once it has had findBy's full second to
- * appear; checking straight away passed whenever no earlier test had loaded
- * that modal yet.
- */
- async function expectNoPreviewOpened() {
- await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
- }
- describe('FileManagerPage preview opening', () => {
- beforeEach(() => {
- mockNavigate.mockClear();
- // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
- // the view mode is programmed rather than written.
- (localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
- server.use(
- http.get('/api/v1/library/folders', () => HttpResponse.json([])),
- http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
- http.get('/api/v1/library/stats', () =>
- HttpResponse.json({
- total_files: mockFiles.length,
- total_folders: 0,
- total_size_bytes: 1024,
- disk_free_bytes: 1024 * 1024,
- disk_total_bytes: 2048 * 1024,
- }),
- ),
- http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
- http.get('/api/v1/projects/', () => HttpResponse.json([])),
- http.get('/api/v1/archives/', () => HttpResponse.json([])),
- );
- });
- describe('double-click in the grid', () => {
- it('opens the image preview for a PNG and leaves the selection alone', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- await user.dblClick(card('photo.png'));
- expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
- // The two clicks of a double-click toggle the selection twice.
- expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
- });
- it('opens the 3D viewer for an STL', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('bracket.stl');
- await user.dblClick(card('bracket.stl'));
- expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
- });
- it('sends a sliced file to the gcode viewer route', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('benchy.gcode.3mf');
- await user.dblClick(card('benchy.gcode.3mf'));
- expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
- });
- it('does nothing for a file with no preview', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('notes.md');
- await user.dblClick(card('notes.md'));
- await expectNoPreviewOpened();
- expect(mockNavigate).not.toHaveBeenCalled();
- });
- // The card's own controls are not "the row": stopping their click is not
- // enough, because dblclick is a separate native event (#2976).
- it('ignores a double-click on the card menu button', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- const imageCard = card('photo.png');
- const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
- await user.dblClick(kebab);
- await expectNoPreviewOpened();
- });
- // The chip's own click toggles the tag filter and re-renders the list, so
- // the dblclick is fired directly: what is under test is whether it bubbles
- // to the card, not what the two clicks before it did.
- it('ignores a double-click on a tag chip', async () => {
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
- await expectNoPreviewOpened();
- });
- // The notes and photos indicators open the file's details; a double-click
- // on one must not open the preview on top of them.
- it('ignores a double-click on the notes indicator', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
- expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
- await expectNoPreviewOpened();
- });
- });
- describe('the list view', () => {
- beforeEach(() => {
- (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
- key === 'library-view-mode' ? 'list' : null,
- );
- });
- it('opens the document preview for a PDF', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('drawing.pdf');
- await user.dblClick(row('drawing.pdf'));
- expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
- });
- it('opens the spreadsheet preview for a CSV', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('parts.csv');
- await user.dblClick(row('parts.csv'));
- expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
- });
- it('offers an image file the same action-strip preview button as a document', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- await user.click(within(row('photo.png')).getByTitle('Preview'));
- expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
- });
- it('ignores a double-click on the row action strip', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- // An impatient double-tap on Rename must not also open the preview.
- await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
- await expectNoPreviewOpened();
- });
- it('ignores a double-click on the row tag cell', async () => {
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
- await expectNoPreviewOpened();
- });
- });
- describe('the toolbar Preview button', () => {
- it('appears for a single previewable selection and opens the preview', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- await user.click(card('photo.png'));
- const preview = await screen.findByRole('button', { name: 'Preview' });
- await user.click(preview);
- expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
- });
- it('stays away for a multi-file selection', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- await user.click(card('photo.png'));
- expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
- await user.click(card('drawing.pdf'));
- await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
- });
- it('stays away for a file with no preview', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('notes.md');
- await user.click(card('notes.md'));
- expect(await screen.findByText('1 selected')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
- });
- });
- // The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
- // so the preview is not offered rather than downloading 50 MB to fail (#2976).
- describe('a TIFF file', () => {
- it('gets no Preview entry in the card menu and no toolbar button', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('scan.tif');
- await user.click(card('scan.tif'));
- expect(await screen.findByText('1 selected')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
- const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
- await user.click(kebab);
- expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
- });
- it('does nothing on double-click', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('scan.tif');
- await user.dblClick(card('scan.tif'));
- await expectNoPreviewOpened();
- expect(mockNavigate).not.toHaveBeenCalled();
- });
- });
- it('offers an image file a Preview entry in the card menu', async () => {
- const user = userEvent.setup();
- render(<FileManagerPage />);
- await screen.findByText('photo.png');
- const imageCard = card('photo.png');
- const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
- await user.click(kebab);
- await user.click(within(imageCard).getByText('Preview'));
- expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
- });
- });
|