diff --git a/CHANGELOG.md b/CHANGELOG.md index 2291fe946..dbc72c4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.8b] - Not released ### Enhanced +- **3D Model Viewer Improvements** (PR #262): + - Added plate selector for multi-plate 3MF files with thumbnail previews + - Object count display shows number of objects per plate and total + - Fullscreen toggle for immersive model viewing + - Resizable split view between plate selector and 3D viewer in fullscreen mode + - Pagination support for files with many plates (e.g., 50+ plates) + - Added i18n translations for all model viewer strings (English, German, Japanese) - **Virtual Printer Proxy Mode Improvements**: - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990 diff --git a/backend/app/main.py b/backend/app/main.py index be0d8db90..5aead77d3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2667,8 +2667,8 @@ async def auth_middleware(request, call_next): # Validate JWT token import jwt - try: + try: from backend.app.core.auth import ALGORITHM, SECRET_KEY token = auth_header.replace("Bearer ", "") diff --git a/backend/tests/unit/test_plate_object_extraction.py b/backend/tests/unit/test_plate_object_extraction.py new file mode 100644 index 000000000..692d0e82b --- /dev/null +++ b/backend/tests/unit/test_plate_object_extraction.py @@ -0,0 +1,204 @@ +"""Unit tests for plate object extraction from 3MF model_settings.config.""" + +from xml.etree import ElementTree as ET + +import pytest + + +class TestPlateObjectExtraction: + """Tests for extracting object IDs and names from model_settings.config XML.""" + + def test_extract_object_names_from_xml(self): + """Verify object names are extracted from model_settings.config XML.""" + xml_content = """ + + + + + + + + + + + + """ + root = ET.fromstring(xml_content) + + object_names_by_id = {} + for obj in root.findall(".//object"): + obj_id = obj.get("id") + if obj_id: + name_meta = obj.find("./metadata[@key='name']") + if name_meta is not None: + object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}") + else: + object_names_by_id[obj_id] = f"Object {obj_id}" + + assert object_names_by_id == { + "1": "Cube", + "2": "Sphere", + "3": "Cylinder", + } + + def test_extract_object_names_missing_name(self): + """Verify objects without names get default names.""" + xml_content = """ + + + + + + + + + """ + root = ET.fromstring(xml_content) + + object_names_by_id = {} + for obj in root.findall(".//object"): + obj_id = obj.get("id") + if obj_id: + name_meta = obj.find("./metadata[@key='name']") + if name_meta is not None: + object_names_by_id[obj_id] = name_meta.get("value", f"Object {obj_id}") + else: + object_names_by_id[obj_id] = f"Object {obj_id}" + + assert object_names_by_id == { + "1": "Named Object", + "2": "Object 2", + } + + def test_extract_plate_object_associations(self): + """Verify plate-to-object associations are extracted correctly.""" + xml_content = """ + + + + + + + + + + + + + + + + + + """ + root = ET.fromstring(xml_content) + + plate_object_ids = {} + for plate in root.findall(".//plate"): + plate_id = None + for meta in plate.findall("metadata"): + if meta.get("key") in ("plater_id", "plate_id"): + plate_id = meta.get("value") + break + + if plate_id: + object_ids = [] + for instance in plate.findall(".//model_instance"): + for meta in instance.findall("metadata"): + if meta.get("key") == "object_id": + object_ids.append(meta.get("value")) + plate_object_ids[plate_id] = object_ids + + assert plate_object_ids == { + "1": ["1", "2"], + "2": ["3"], + } + + def test_extract_plate_object_associations_empty_plate(self): + """Verify empty plates have empty object lists.""" + xml_content = """ + + + + + + + """ + root = ET.fromstring(xml_content) + + plate_object_ids = {} + for plate in root.findall(".//plate"): + plate_id = None + for meta in plate.findall("metadata"): + if meta.get("key") in ("plater_id", "plate_id"): + plate_id = meta.get("value") + break + + if plate_id: + object_ids = [] + for instance in plate.findall(".//model_instance"): + for meta in instance.findall("metadata"): + if meta.get("key") == "object_id": + object_ids.append(meta.get("value")) + plate_object_ids[plate_id] = object_ids + + assert plate_object_ids == {"1": []} + + def test_object_count_matches_objects_length(self): + """Verify object_count equals len(objects).""" + objects = ["Cube", "Sphere", "Cylinder"] + object_count = len(objects) + + assert object_count == 3 + + def test_resolve_object_names_from_ids(self): + """Verify object IDs are resolved to names.""" + object_names_by_id = { + "1": "Cube", + "2": "Sphere", + "3": "Cylinder", + } + plate_object_ids = ["1", "3"] + + resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids] + + assert resolved_names == ["Cube", "Cylinder"] + + def test_resolve_object_names_missing_id(self): + """Verify missing object IDs get fallback names.""" + object_names_by_id = { + "1": "Cube", + } + plate_object_ids = ["1", "99"] # 99 doesn't exist + + resolved_names = [object_names_by_id.get(obj_id, f"Object {obj_id}") for obj_id in plate_object_ids] + + assert resolved_names == ["Cube", "Object 99"] + + def test_plate_id_alternatives(self): + """Verify both 'plater_id' and 'plate_id' keys are supported.""" + xml_with_plater_id = """ + + + + + + """ + xml_with_plate_id = """ + + + + + + """ + + def extract_plate_id(xml_content): + root = ET.fromstring(xml_content) + for plate in root.findall(".//plate"): + for meta in plate.findall("metadata"): + if meta.get("key") in ("plater_id", "plate_id"): + return meta.get("value") + return None + + assert extract_plate_id(xml_with_plater_id) == "1" + assert extract_plate_id(xml_with_plate_id) == "2" diff --git a/frontend/src/__tests__/components/ModelViewerModal.test.tsx b/frontend/src/__tests__/components/ModelViewerModal.test.tsx new file mode 100644 index 000000000..09b0b1da5 --- /dev/null +++ b/frontend/src/__tests__/components/ModelViewerModal.test.tsx @@ -0,0 +1,503 @@ +/** + * Tests for the ModelViewerModal component. + * Tests fullscreen toggle, plate selector, object counts, and tab switching. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { ModelViewerModal } from '../../components/ModelViewerModal'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests +vi.mock('../../components/ModelViewer', () => ({ + ModelViewer: ({ className }: { className?: string }) => ( +
+ Model Viewer Mock +
+ ), +})); + +vi.mock('../../components/GcodeViewer', () => ({ + GcodeViewer: ({ className }: { className?: string }) => ( +
+ G-code Viewer Mock +
+ ), +})); + +const mockCapabilities = { + has_model: true, + has_gcode: true, + has_source: false, + build_volume: { x: 256, y: 256, z: 256 }, + filament_colors: ['#00ae42'], +}; + +const mockPlatesResponse = { + is_multi_plate: true, + plates: [ + { + index: 1, + name: 'Plate 1', + has_thumbnail: true, + thumbnail_url: '/api/v1/archives/1/plates/1/thumbnail', + print_time_seconds: 3600, + filament_used_grams: 50.5, + object_count: 3, + objects: ['Cube', 'Sphere', 'Cylinder'], + filaments: [{ color: '#00ae42', type: 'PLA', name: 'Bambu PLA Basic' }], + }, + { + index: 2, + name: 'Plate 2', + has_thumbnail: true, + thumbnail_url: '/api/v1/archives/1/plates/2/thumbnail', + print_time_seconds: 1800, + filament_used_grams: 25.0, + object_count: 2, + objects: ['Base', 'Cover'], + filaments: [{ color: '#ff0000', type: 'PLA', name: 'Red PLA' }], + }, + ], +}; + +const mockSinglePlateResponse = { + is_multi_plate: false, + plates: [ + { + index: 1, + name: null, + has_thumbnail: false, + thumbnail_url: null, + print_time_seconds: 7200, + filament_used_grams: 100.0, + object_count: 5, + objects: ['Model 1', 'Model 2', 'Model 3', 'Model 4', 'Model 5'], + filaments: [], + }, + ], +}; + +describe('ModelViewerModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get('/api/v1/archives/:id/capabilities', () => { + return HttpResponse.json(mockCapabilities); + }), + http.get('/api/v1/archives/:id/plates', () => { + return HttpResponse.json(mockPlatesResponse); + }) + ); + }); + + describe('rendering', () => { + it('renders the modal with title', async () => { + render( + + ); + + expect(screen.getByText('Test Model.3mf')).toBeInTheDocument(); + }); + + it('renders Open in Slicer button', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Open in Slicer')).toBeInTheDocument(); + }); + }); + + it('shows loading spinner while fetching capabilities', () => { + server.use( + http.get('/api/v1/archives/:id/capabilities', async () => { + await new Promise((r) => setTimeout(r, 100)); + return HttpResponse.json(mockCapabilities); + }) + ); + + render( + + ); + + const loader = document.querySelector('.animate-spin'); + expect(loader).toBeInTheDocument(); + }); + }); + + describe('tabs', () => { + it('renders 3D Model and G-code tabs', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('3D Model')).toBeInTheDocument(); + expect(screen.getByText('G-code Preview')).toBeInTheDocument(); + }); + }); + + it('shows not available label when model is not available', async () => { + server.use( + http.get('/api/v1/archives/:id/capabilities', () => { + return HttpResponse.json({ + ...mockCapabilities, + has_model: false, + }); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('(not available)')).toBeInTheDocument(); + }); + }); + + it('shows not sliced label when gcode is not available', async () => { + server.use( + http.get('/api/v1/archives/:id/capabilities', () => { + return HttpResponse.json({ + ...mockCapabilities, + has_gcode: false, + }); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('(not sliced)')).toBeInTheDocument(); + }); + }); + + it('disables tab when capability is not available', async () => { + server.use( + http.get('/api/v1/archives/:id/capabilities', () => { + return HttpResponse.json({ + ...mockCapabilities, + has_gcode: false, + }); + }) + ); + + render( + + ); + + await waitFor(() => { + const gcodeTab = screen.getByText('G-code Preview').closest('button'); + expect(gcodeTab).toBeDisabled(); + }); + }); + }); + + describe('fullscreen', () => { + it('renders fullscreen toggle button', async () => { + render( + + ); + + await waitFor(() => { + // Look for the maximize icon button + const buttons = screen.getAllByRole('button'); + const fullscreenButton = buttons.find( + (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen' + ); + expect(fullscreenButton).toBeInTheDocument(); + }); + }); + }); + + describe('object count', () => { + it('displays object count for multi-plate files', async () => { + render( + + ); + + await waitFor(() => { + // Total objects across both plates = 3 + 2 = 5 + // The header shows "All Plates: 5 objects" in a span + const objectCountBadge = screen.getByText(/All Plates.*5 objects/); + expect(objectCountBadge).toBeInTheDocument(); + }); + }); + + it('updates object count when plate is selected', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Plate 1')).toBeInTheDocument(); + }); + + // Click on Plate 1 + fireEvent.click(screen.getByText('Plate 1')); + + await waitFor(() => { + // Plate 1 has 3 objects - header should update to show "Plate 1: 3 objects" + const objectCountBadge = screen.getByText(/Plate 1.*3 objects/); + expect(objectCountBadge).toBeInTheDocument(); + }); + }); + }); + + describe('plate selector', () => { + it('shows plates panel for multi-plate files', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Plates')).toBeInTheDocument(); + // Use getAllByText for "All Plates" since it appears in header and panel + const allPlatesElements = screen.getAllByText('All Plates'); + expect(allPlatesElements.length).toBeGreaterThan(0); + expect(screen.getByText('2 plates')).toBeInTheDocument(); + }); + }); + + it('shows individual plate buttons', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Plate 1')).toBeInTheDocument(); + expect(screen.getByText('Plate 2')).toBeInTheDocument(); + }); + }); + + it('shows object count for each plate', async () => { + render( + + ); + + await waitFor(() => { + // Each plate shows its object count in the grid + expect(screen.getByText('3 objects')).toBeInTheDocument(); + expect(screen.getByText('2 objects')).toBeInTheDocument(); + }); + }); + + it('hides plates panel for single-plate files', async () => { + server.use( + http.get('/api/v1/archives/:id/plates', () => { + return HttpResponse.json(mockSinglePlateResponse); + }) + ); + + render( + + ); + + await waitFor(() => { + // Should show object count but not plate selector + expect(screen.getByText(/5 objects/)).toBeInTheDocument(); + }); + + // Plates panel should not be shown for single plate + expect(screen.queryByText('2 plates')).not.toBeInTheDocument(); + }); + + it('selects All Plates by default', async () => { + render( + + ); + + await waitFor(() => { + // Find the All Plates button in the grid (the one with "2 plates" sibling text) + const platesCountText = screen.getByText('2 plates'); + const allPlatesButton = platesCountText.closest('button'); + // The selected button should have the green border class + expect(allPlatesButton).toHaveClass('border-bambu-green'); + }); + }); + + it('allows plate selection via click', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Plate 1')).toBeInTheDocument(); + }); + + // Click on Plate 1 - this should not throw + const plate1Button = screen.getByText('Plate 1').closest('button'); + expect(plate1Button).toBeInTheDocument(); + fireEvent.click(plate1Button!); + + // After clicking, the header should show Plate 1 info + await waitFor(() => { + expect(screen.getByText(/Plate 1.*3 objects/)).toBeInTheDocument(); + }); + }); + }); + + describe('close behavior', () => { + it('calls onClose when X button is clicked', async () => { + render( + + ); + + const closeButton = screen.getAllByRole('button').find( + (btn) => btn.querySelector('.lucide-x') + ); + + if (closeButton) { + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + } + }); + + it('calls onClose when Escape key is pressed', () => { + render( + + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('calls onClose when backdrop is clicked', () => { + render( + + ); + + const backdrop = document.querySelector('.fixed.inset-0'); + if (backdrop) { + fireEvent.click(backdrop); + expect(mockOnClose).toHaveBeenCalled(); + } + }); + }); + + describe('library file mode', () => { + it('renders for library file', async () => { + server.use( + http.get('/api/v1/library/files/:id/plates', () => { + return HttpResponse.json(mockSinglePlateResponse); + }) + ); + + render( + + ); + + expect(screen.getByText('Library Model.3mf')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('3D Model')).toBeInTheDocument(); + }); + }); + + it('disables Open in Slicer for non-3mf library files', async () => { + render( + + ); + + await waitFor(() => { + const slicerButton = screen.getByText('Open in Slicer').closest('button'); + expect(slicerButton).toBeDisabled(); + }); + }); + }); +}); diff --git a/test_backend.sh b/test_backend.sh index 38e0c415b..a5a43970e 100755 --- a/test_backend.sh +++ b/test_backend.sh @@ -1,5 +1,6 @@ #!/bin/sh cd backend +ruff check && ruff format --check ../venv/bin/python3 -m pytest tests/ -v -n 14 cd ..