/** * Tests for the ProjectDetailPage component. * Covers: isSlicedFilename conditional print-button logic, linked folder file rendering, * and the PrintModal open trigger with projectId. */ /// 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 { ProjectDetailPage } from '../../pages/ProjectDetailPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; // Mock useParams so the component receives a fixed project id without a nested Router vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useParams: () => ({ id: '1' }), useNavigate: () => vi.fn(), }; }); const mockProject = { id: 1, name: 'Test Project', description: 'A test project', color: '#00ae42', status: 'active', priority: 'normal', due_date: null, notes: null, parent_id: null, archive_count: 0, total_print_time_seconds: 0, total_filament_grams: 0, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; const mockFolder = { id: 10, name: 'Sliced Files', project_id: 1, archive_id: null, parent_id: null, file_count: 3, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; function makeFile(overrides: { id: number; filename: string; file_type?: string }) { return { id: overrides.id, filename: overrides.filename, print_name: null, file_type: overrides.file_type ?? '3mf', folder_id: 10, project_id: 1, file_hash: null, file_size_bytes: 1024, thumbnail_path: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', duplicate_count: 0, }; } describe('ProjectDetailPage', () => { beforeEach(() => { server.use( http.get('/api/v1/projects/:id', () => { return HttpResponse.json(mockProject); }), http.get('/api/v1/projects/:id/archives', () => { return HttpResponse.json([]); }), http.get('/api/v1/projects/:id/bom', () => { return HttpResponse.json([]); }), http.get('/api/v1/projects/:id/timeline', () => { return HttpResponse.json([]); }), http.get('/api/v1/library/folders/by-project/:id', () => { return HttpResponse.json([mockFolder]); }), ); }); describe('isSlicedFilename — conditional print button', () => { it('shows print button for .gcode files', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]); }) ); render(); await waitFor(() => { expect(screen.getByTitle('Print')).toBeInTheDocument(); }); }); it('shows print button for .gcode.3mf files', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]); }) ); render(); await waitFor(() => { expect(screen.getByTitle('Print')).toBeInTheDocument(); }); }); it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]); }) ); render(); await waitFor(() => { expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument(); }); expect(screen.queryByTitle('Print')).not.toBeInTheDocument(); }); it('does NOT show print button for .stl files', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]); }) ); render(); await waitFor(() => { expect(screen.getByText('model.stl')).toBeInTheDocument(); }); expect(screen.queryByTitle('Print')).not.toBeInTheDocument(); }); }); describe('linked folder file rendering', () => { it('renders filenames from linked folder', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([ makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }), makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }), ]); }) ); render(); await waitFor(() => { expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument(); expect(screen.getByText('design.stl')).toBeInTheDocument(); }); }); it('renders the linked folder name', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([]); }) ); render(); await waitFor(() => { expect(screen.getByText('Sliced Files')).toBeInTheDocument(); }); }); }); describe('print modal trigger', () => { it('opens PrintModal when print button is clicked on a sliced file', async () => { const user = userEvent.setup(); server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]); }), http.get('/api/v1/printers/', () => { return HttpResponse.json([]); }), http.get('/api/v1/library/files/:id', () => { return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })); }), http.get('/api/v1/library/files/:id/plates', () => { return HttpResponse.json({ is_multi_plate: false, plates: [] }); }), http.get('/api/v1/library/files/:id/filament-requirements', () => { return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] }); }), ); render(); await waitFor(() => { expect(screen.getByTitle('Print')).toBeInTheDocument(); }); await user.click(screen.getByTitle('Print')); // PrintModal should open — look for the modal heading "Print" await waitFor(() => { expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument(); }); }); }); describe('per-file print progress (#1897)', () => { it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => { server.use( http.get('/api/v1/projects/:id', () => { return HttpResponse.json({ ...mockProject, target_sets: 10 }); }), http.get('/api/v1/library/files', () => { return HttpResponse.json([ makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }), makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }), ]); }), http.get('/api/v1/projects/:id/file-progress', () => { return HttpResponse.json([{ file_id: 5, completed_count: 3 }]); }), ); render(); // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2 await waitFor(() => { expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument(); }); expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument(); // Complete sets = min across printable files = 0 expect(screen.getByText('Complete Sets')).toBeInTheDocument(); expect( screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets') ).toBeInTheDocument(); }); it('counts a complete set once every printable file reached the target', async () => { server.use( http.get('/api/v1/projects/:id', () => { return HttpResponse.json({ ...mockProject, target_sets: 2 }); }), http.get('/api/v1/library/files', () => { return HttpResponse.json([ makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }), // STL is not printable and must not drag the set count to 0 makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }), makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }), ]); }), http.get('/api/v1/projects/:id/file-progress', () => { // plate_1 overshot the target; capped at 2 for the set count return HttpResponse.json([ { file_id: 5, completed_count: 3 }, { file_id: 6, completed_count: 2 }, ]); }), ); render(); await waitFor(() => { expect( screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets') ).toBeInTheDocument(); }); }); it('shows a plain printed-count badge when no target_sets is set', async () => { server.use( http.get('/api/v1/library/files', () => { return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]); }), http.get('/api/v1/projects/:id/file-progress', () => { return HttpResponse.json([{ file_id: 5, completed_count: 4 }]); }), ); render(); await waitFor(() => { expect(screen.getByText('4\u00d7')).toBeInTheDocument(); }); // No sets bar without a target expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument(); }); }); describe('sub-project roll-up (#1264)', () => { const withChildren = { ...mockProject, // Supplying `stats` opens the cost card, which reads `budget` — the API // always sends the key, so the mock has to as well. budget: null, descendant_count: 2, children: [ { id: 2, name: 'Wing', color: '#ff0000', status: 'active', progress_percent: 50, descendant_count: 1, total_archives: 4, completed_prints: 4, total_print_time_hours: 6, total_filament_grams: 250, total_cost: 12.5, }, ], stats: { total_archives: 1, total_items: 1, completed_prints: 1, failed_prints: 0, queued_prints: 0, in_progress_prints: 0, total_print_time_hours: 2, total_filament_grams: 100, progress_percent: null, parts_progress_percent: null, estimated_cost: 5, total_energy_kwh: 0, total_energy_cost: 0, remaining_prints: null, remaining_parts: null, bom_total_items: 0, bom_completed_items: 0, bom_cost: 0, }, rollup_stats: { total_archives: 5, total_items: 5, completed_prints: 5, failed_prints: 0, queued_prints: 0, in_progress_prints: 0, total_print_time_hours: 8, total_filament_grams: 350, progress_percent: 40, parts_progress_percent: null, estimated_cost: 17.5, total_energy_kwh: 0, total_energy_cost: 0, remaining_prints: 3, remaining_parts: null, bom_total_items: 0, bom_completed_items: 0, bom_cost: 0, }, }; it('shows the whole programme alongside the project own numbers', async () => { server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren))); render(); await waitFor(() => { expect(screen.getByText('Including 2 sub-projects')).toBeInTheDocument(); }); // Both sets are on screen at once, which is the point — the roll-up must // not quietly replace what the project itself printed. expect(screen.getByText('350g')).toBeInTheDocument(); expect(screen.getByText('100g')).toBeInTheDocument(); }); it('stays quiet when the project has no sub-projects', async () => { // Reversion-proof: if the roll-up were computed unconditionally it would // print a second, identical set of figures under every ordinary project. server.use( http.get('/api/v1/projects/:id', () => HttpResponse.json({ ...withChildren, children: [], descendant_count: 0, rollup_stats: null }) ) ); render(); await waitFor(() => { expect(screen.getByText('100g')).toBeInTheDocument(); }); expect(screen.queryByText(/Including .* sub-projects/)).not.toBeInTheDocument(); }); it('gives each listed sub-project its own branch total', async () => { server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren))); render(); await waitFor(() => { expect(screen.getByText('Wing')).toBeInTheDocument(); }); expect(screen.getByText('4 jobs')).toBeInTheDocument(); expect(screen.getByText('250g')).toBeInTheDocument(); expect(screen.getByText('50%')).toBeInTheDocument(); }); it('marks a sub-project that is itself a parent', async () => { // Otherwise its figures look inflated for a single project rather than // covering the branch under it. server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren))); render(); await waitFor(() => { expect(screen.getByText('Wing')).toBeInTheDocument(); }); const row = screen.getByText('Wing').closest('a'); expect(row).not.toBeNull(); expect(row!.textContent).toContain('1'); }); }); });