Просмотр исходного кода

- Added frontnend/backend tests for printer controls

maziggy 4 месяцев назад
Родитель
Сommit
fe250b9b7f

+ 22 - 0
CHANGELOG.md

@@ -2,6 +2,28 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b5] - 2026-01-01
+
+### Added
+- **Printer control buttons** - Stop and Pause/Resume buttons on printer cards when printing:
+  - Stop button cancels the current print job
+  - Pause/Resume toggle for pausing and resuming prints
+  - Confirmation modals for all actions to prevent accidental clicks
+  - Toast notifications for action feedback
+- **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
+  - Menu button (⋮) appears on hover over AMS slots
+  - "Re-read RFID" option triggers filament info refresh
+  - Loading indicator shows while re-read is in progress
+  - Automatically tracks printer status to clear indicator when complete
+  - Menu hidden when printer is busy (printing)
+
+### Changed
+- **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
+
+### Tests
+- Added integration tests for printer control endpoints (stop, pause, resume)
+- Added integration tests for AMS slot refresh endpoint
+
 ## [0.1.6b4] - 2026-01-01
 
 ### Added

+ 3 - 1
README.md

@@ -52,9 +52,11 @@
 - Re-print to any connected printer with AMS filament preview
 - Archive comparison (side-by-side diff)
 
-### 📊 Monitoring & Stats
+### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
+- Printer control (stop, pause, resume)
+- AMS slot RFID re-read
 - HMS error monitoring with history
 - Print success rates & trends
 - Filament usage tracking

+ 91 - 0
backend/app/api/routes/printers.py

@@ -1011,3 +1011,94 @@ async def debug_simulate_print_complete(
     await on_print_complete(printer_id, data)
 
     return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
+
+
+# =============================================================================
+# Print Control Endpoints
+# =============================================================================
+
+
+@router.post("/{printer_id}/print/stop")
+async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Stop/cancel the current print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.stop_print()
+    if not success:
+        raise HTTPException(500, "Failed to stop print")
+
+    return {"success": True, "message": "Print stop command sent"}
+
+
+@router.post("/{printer_id}/print/pause")
+async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Pause the current print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.pause_print()
+    if not success:
+        raise HTTPException(500, "Failed to pause print")
+
+    return {"success": True, "message": "Print pause command sent"}
+
+
+@router.post("/{printer_id}/print/resume")
+async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Resume a paused print job."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.resume_print()
+    if not success:
+        raise HTTPException(500, "Failed to resume print")
+
+    return {"success": True, "message": "Print resume command sent"}
+
+
+# =============================================================================
+# AMS Control Endpoints
+# =============================================================================
+
+
+@router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
+async def refresh_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    slot_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Re-read RFID for an AMS slot (triggers filament info refresh)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success, message = client.ams_refresh_tray(ams_id, slot_id)
+    if not success:
+        raise HTTPException(400, message)
+
+    return {"success": True, "message": message}

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b4"
+APP_VERSION = "0.1.6b5"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 194 - 0
backend/tests/integration/test_printers_api.py

@@ -291,3 +291,197 @@ class TestPrinterDataIntegrity:
             assert response.status_code == 200
             assert response.json()["status"] == "refresh_requested"
             mock_pm.request_status_update.assert_called_once_with(printer.id)
+
+
+class TestPrintControlAPI:
+    """Integration tests for print control endpoints (stop, pause, resume)."""
+
+    # ========================================================================
+    # Stop print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/stop")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful stop print request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.stop_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.stop_print.assert_called_once()
+
+    # ========================================================================
+    # Pause print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/pause")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful pause print request."""
+        printer = await printer_factory(name="Printing Printer")
+
+        mock_client = MagicMock()
+        mock_client.pause_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.pause_print.assert_called_once()
+
+    # ========================================================================
+    # Resume print endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print/resume")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful resume print request."""
+        printer = await printer_factory(name="Paused Printer")
+
+        mock_client = MagicMock()
+        mock_client.resume_print.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+            mock_client.resume_print.assert_called_once()
+
+
+class TestAMSRefreshAPI:
+    """Integration tests for AMS slot refresh endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful AMS refresh request."""
+        printer = await printer_factory(name="Printer with AMS")
+
+        mock_client = MagicMock()
+        mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
+        """Verify error when filament is loaded (can't refresh while loaded)."""
+        printer = await printer_factory(name="Printer with AMS")
+
+        mock_client = MagicMock()
+        mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
+
+            assert response.status_code == 400
+            assert "unload" in response.json()["detail"].lower()

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-3UvyJ1HQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BJk0xs4c.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CCbBv2VC.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-3umWYOC3.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CCbBv2VC.css">
+    <script type="module" crossorigin src="/assets/index-3UvyJ1HQ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BJk0xs4c.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов