/** * Tests for the ProjectsPage component. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; const mockProjects = [ { id: 1, name: 'Functional Parts', description: 'Useful household items', color: '#00ae42', archive_count: 10, total_print_time_seconds: 36000, total_filament_grams: 500, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-15T00:00:00Z', }, { id: 2, name: 'Art Collection', description: 'Decorative prints', color: '#ff5500', archive_count: 5, total_print_time_seconds: 18000, total_filament_grams: 200, created_at: '2024-01-05T00:00:00Z', updated_at: '2024-01-10T00:00:00Z', }, ]; describe('ProjectsPage', () => { beforeEach(() => { server.use( http.get('/api/v1/projects/', () => { return HttpResponse.json(mockProjects); }), http.post('/api/v1/projects/', async ({ request }) => { const body = await request.json() as { name: string }; return HttpResponse.json({ id: 3, name: body.name, color: '#00ae42', archive_count: 0 }); }), http.delete('/api/v1/projects/:id', () => { return HttpResponse.json({ success: true }); }) ); }); describe('rendering', () => { it('renders the page title', async () => { render(); await waitFor(() => { expect(screen.getByText('Projects')).toBeInTheDocument(); }); }); it('shows project cards', async () => { render(); await waitFor(() => { expect(screen.getByText('Functional Parts')).toBeInTheDocument(); expect(screen.getByText('Art Collection')).toBeInTheDocument(); }); }); it('shows project descriptions', async () => { render(); await waitFor(() => { expect(screen.getByText('Useful household items')).toBeInTheDocument(); expect(screen.getByText('Decorative prints')).toBeInTheDocument(); }); }); }); describe('project info', () => { it('shows archive count', async () => { render(); await waitFor(() => { // Project cards should show archive counts expect(screen.getByText('Functional Parts')).toBeInTheDocument(); }); }); it('shows project colors', async () => { render(); await waitFor(() => { const functionalParts = screen.getByText('Functional Parts'); expect(functionalParts).toBeInTheDocument(); // Color is applied as style }); }); }); describe('create project', () => { it('has new project button', async () => { render(); await waitFor(() => { expect(screen.getByText('New Project')).toBeInTheDocument(); }); }); it('opens create modal on click', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('New Project')).toBeInTheDocument(); }); await user.click(screen.getByText('New Project')); // Modal should open - look for modal content await waitFor(() => { // Modal may show "Create Project" or similar text const modalContent = screen.queryByText(/create/i) || screen.queryByRole('dialog') || screen.queryByText(/name/i); expect(modalContent).toBeTruthy(); }); }); }); describe('empty state', () => { it('shows empty state when no projects', async () => { server.use( http.get('/api/v1/projects/', () => { return HttpResponse.json([]); }) ); render(); await waitFor(() => { // Either empty state message or the page title should be visible const emptyMsg = screen.queryByText(/no projects/i); const pageTitle = screen.queryByText('Projects'); expect(emptyMsg || pageTitle).toBeTruthy(); }); }); }); // #1155 — URL link icon + cover image thumbnail on project cards. describe('URL link and cover image (#1155)', () => { it('renders an external-link icon next to the project name when URL is set', async () => { server.use( http.get('/api/v1/projects/', () => HttpResponse.json([ { ...mockProjects[0], url: 'https://makerworld.com/models/12345', cover_image_filename: null, }, ]) ) ); render(); const link = await screen.findByLabelText(/Open project URL/i); expect(link).toBeInTheDocument(); expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345'); expect(link.getAttribute('target')).toBe('_blank'); expect(link.getAttribute('rel')).toContain('noopener'); }); it('does not render the link icon when URL is not set', async () => { // Default fixture has no `url` field — verify the icon is absent. render(); await waitFor(() => { expect(screen.getByText('Functional Parts')).toBeInTheDocument(); }); expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument(); }); it('clicking the URL link does not bubble to the card onClick', async () => { server.use( http.get('/api/v1/projects/', () => HttpResponse.json([ { ...mockProjects[0], url: 'https://example.com', cover_image_filename: null, }, ]) ) ); const user = userEvent.setup(); render(); const link = await screen.findByLabelText(/Open project URL/i); // Prevent the underlying anchor from triggering jsdom navigation noise // — we only need the propagation guard verified. link.addEventListener('click', (e) => e.preventDefault(), { once: true }); await user.click(link); // No navigate / detail-page transition should have happened. Card root // is still rendered. expect(screen.getByText('Functional Parts')).toBeInTheDocument(); }); it('renders a cover image thumbnail when cover_image_filename is set', async () => { server.use( http.get('/api/v1/projects/', () => HttpResponse.json([ { ...mockProjects[0], url: null, cover_image_filename: 'cover_abc.png', }, ]) ) ); render(); const img = await screen.findByAltText(/Project cover photo/i); expect(img).toBeInTheDocument(); // Card thumbnail uses the GET endpoint URL, project.id is 1. expect(img.getAttribute('src')).toContain('/projects/1/cover-image'); }); it('renders the portal-mounted hover preview only while the thumbnail is hovered (#1155)', async () => { // The portal escapes the project card's ``overflow-hidden``, which // would otherwise clip a 384×384 popover anchored to a 40×40 // thumbnail. Pin the contract end-to-end: // - no preview in the DOM by default (mouseenter not fired yet) // - mouseenter mounts a popover at document.body level (not // nested in the card subtree, which would re-introduce the // clipping bug) // - mouseleave unmounts it // - the popover ```` points at the same cover-image URL as // the small thumbnail (object-contain so portrait/landscape // MakerWorld photos aren't cropped) const { fireEvent } = await import('@testing-library/react'); server.use( http.get('/api/v1/projects/', () => HttpResponse.json([ { ...mockProjects[0], url: null, cover_image_filename: 'cover_abc.png', }, ]) ) ); render(); const thumb = await screen.findByAltText(/Project cover photo/i); // Default: no popover yet. expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0); // Hover: walk up to the wrapper div the component attaches its // mouseenter to. The portal mounts under document.body, NOT // nested in the card subtree. const wrapper = thumb.closest('[class*="flex-shrink-0"]') as HTMLElement; expect(wrapper).not.toBeNull(); fireEvent.mouseEnter(wrapper); const previewImg = document.querySelector('[aria-hidden="true"] img') as HTMLImageElement | null; expect(previewImg).not.toBeNull(); expect(previewImg!.getAttribute('src')).toContain('/projects/1/cover-image'); expect(previewImg!.className).toContain('object-contain'); // The portal mounts on document.body (or one of its direct // descendants), not inside the card — that's the whole point of // the portal, so a future refactor that drops the portal would // re-introduce the clipping regression. const popover = previewImg!.closest('[aria-hidden="true"]') as HTMLElement; expect(popover.closest('[class*="rounded-xl"]')).toBeNull(); // Unmount on leave. fireEvent.mouseLeave(wrapper); expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0); }); it('does not render a hover preview when there is no cover image', async () => { server.use( http.get('/api/v1/projects/', () => HttpResponse.json([ { ...mockProjects[0], url: null, cover_image_filename: null }, ]) ) ); render(); await screen.findByText(mockProjects[0].name); expect(screen.queryByAltText(/Project cover photo/i)).toBeNull(); // No aria-hidden img should ever appear because no thumbnail to // hover means the portal-mounting component never renders. expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0); }); }); describe('modal scrolls on short viewports (#1642)', () => { /** * Reporter on a Pi screen couldn't reach the Save button when editing a * project because the modal had no max-h / overflow. The structural fix * puts a max-h on the card, the form fields in a `flex-1 overflow-y-auto` * wrapper, and the Save/Cancel buttons in a `flex-shrink-0` sibling so * they're always visible regardless of scroll position. * * jsdom doesn't compute layout heights so we can't simulate the actual * overflow. We pin the structure instead: the scrollable wrapper exists, * the Save button is NOT a descendant of it, and the card has a max-h. * A future refactor that removes any of these would re-introduce the bug. */ const editableProject = { id: 7, name: 'Spool holder', description: null, color: '#00ae42', url: null, cover_image_filename: null, archive_count: 0, total_print_time_seconds: 0, total_filament_grams: 0, target_plates_count: null, target_parts_count: null, tags: null, due_date: null, priority: null, budget: null, status: 'active' as const, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; it('renders the action footer outside the scrollable fields wrapper', () => { render( {}} onSave={() => {}} isLoading={false} currencySymbol="€" t={((k: string) => k) as never} />, ); const saveButton = screen.getByRole('button', { name: 'common.save' }); const scrollable = document.querySelector('.overflow-y-auto'); expect(scrollable).not.toBeNull(); // The save button must live OUTSIDE the scrollable region — otherwise // a long form pushes it below the fold on short viewports (#1642). expect(scrollable!.contains(saveButton)).toBe(false); }); it('caps the modal card height so it cannot exceed the viewport', () => { render( {}} onSave={() => {}} isLoading={false} currencySymbol="€" t={((k: string) => k) as never} />, ); // Card has max-h set so it never extends past the viewport — without // this, vertical-center alignment pushes the bottom of the modal // (including the action footer) off-screen. const card = document.querySelector('.max-h-\\[calc\\(100vh-2rem\\)\\]'); expect(card).not.toBeNull(); }); }); describe('edit dialog seeds itself from the list payload (#2536)', () => { /** * The same ProjectModal is opened from the projects list and from the * project detail page. The detail page hands it a full project; the list * hands it a list item. Reporter saw an empty tags field when editing from * the list, because the list payload didn't carry tags — and, unreported, * the dialog then submitted its default priority over the stored one. */ const listItem = { id: 7, name: 'Spool holder', description: null, color: '#00ae42', status: 'active', target_count: null, target_parts_count: null, budget: null, tags: 'prototype,client-work', due_date: '2026-08-01T12:00:00Z', priority: 'high', created_at: '2024-01-01T00:00:00Z', archive_count: 0, total_items: 0, completed_count: 0, failed_count: 0, queue_count: 0, progress_percent: null, archives: [], url: null, cover_image_filename: null, }; const renderModal = (onSave: (data: unknown) => void) => render( {}} onSave={onSave as never} isLoading={false} currencySymbol="€" t={((k: string) => k) as never} />, ); const tagsInput = () => screen.getByPlaceholderText('projects.tagsPlaceholder') as HTMLInputElement; const dueDateInput = () => document.querySelector('input[type="date"]') as HTMLInputElement; const prioritySelect = () => Array.from(document.querySelectorAll('select')).find((s) => s.querySelector('option[value="urgent"]'), ) as HTMLSelectElement; it('prefills tags, due date and priority from a list item', () => { renderModal(() => {}); expect(tagsInput().value).toBe('prototype,client-work'); expect(dueDateInput().value).toBe('2026-08-01'); expect(prioritySelect().value).toBe('high'); }); it('does not downgrade a stored priority when saving an untouched field', async () => { const user = userEvent.setup(); const onSave = vi.fn(); renderModal(onSave); await user.click(screen.getByRole('button', { name: 'common.save' })); // Before the fix the dialog fell back to its 'normal' default and sent // that, silently demoting a high/urgent project edited from the list. expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ priority: 'high', tags: 'prototype,client-work' }), ); }); it('sends null when an existing tag list is cleared', async () => { const user = userEvent.setup(); const onSave = vi.fn(); renderModal(onSave); await user.clear(tagsInput()); await user.click(screen.getByRole('button', { name: 'common.save' })); // undefined would drop the key from the PATCH body and the backend would // keep the old tags — the field has to be cleared explicitly. expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null })); }); }); });