Browse Source

Add tests, docs, and lint fix for AMS Info Card feature (#570)

  - Add 12 backend integration tests for AMS labels API (GET/PUT/DELETE,
    serial resolution, synthetic key fallback, whitespace handling, validation)
  - Add 10 frontend tests for FilamentSlotCircle component (rendering,
    border styles, background colors, text contrast inversion)
  - Fix ruff W293 trailing whitespace in inventory.py from contributor fix
  - Add ams_label model import to test conftest.py
  - Update CHANGELOG, README, website features page, and wiki AMS docs
maziggy 2 months ago
parent
commit
ed462c5cca

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.2b2] - Unreleased
 
+### New Features
+- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
+
 ### Fixed
 - **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
 

+ 1 - 0
README.md

@@ -90,6 +90,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
+- AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history and clear errors
 - Print success rates & trends

+ 2 - 4
backend/app/api/routes/inventory.py

@@ -665,7 +665,7 @@ async def list_assignments(
                         serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
                     except (ValueError, TypeError):
                         continue
-              
+
     # Fetch all relevant AMS labels keyed by serial number
     all_serials = set(serial_map.values())
     # Also include synthetic fallback keys for assignments without a known serial
@@ -678,9 +678,7 @@ async def list_assignments(
 
     label_by_serial: dict[str, str] = {}
     if all_serials:
-        lbl_result = await db.execute(
-            select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials))
-        )
+        lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
         for lbl in lbl_result.scalars().all():
             label_by_serial[lbl.ams_serial_number] = lbl.label
 

+ 1 - 0
backend/tests/conftest.py

@@ -67,6 +67,7 @@ async def test_engine():
     # Import all models to register them
     from backend.app.models import (
         ams_history,
+        ams_label,
         api_key,
         archive,
         external_link,

+ 207 - 0
backend/tests/integration/test_ams_labels_api.py

@@ -0,0 +1,207 @@
+"""Integration tests for AMS Labels API endpoints."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.ams_label import AmsLabel
+
+
+class TestAmsLabelsAPI:
+    """Integration tests for /api/v1/printers/{printer_id}/ams-labels endpoints."""
+
+    def _mock_printer_state(self, ams_units=None):
+        """Create a mock printer state with AMS data."""
+        state = MagicMock()
+        state.connected = True
+        state.raw_data = {
+            "ams": ams_units
+            or [
+                {"id": "0", "sn": "AMS_SERIAL_0"},
+                {"id": "1", "sn": "AMS_SERIAL_1"},
+            ],
+        }
+        return state
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_empty(self, async_client: AsyncClient, printer_factory):
+        """Returns empty dict when no labels are saved."""
+        printer = await printer_factory()
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = self._mock_printer_state()
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+        assert response.status_code == 200
+        assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_with_serial(self, async_client: AsyncClient, printer_factory):
+        """Save a label keyed by AMS serial number."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Workshop AMS", "ams_serial": "AMS_SERIAL_0"},
+        )
+        assert response.status_code == 200
+        assert response.json() == {"ams_id": 0, "label": "Workshop AMS"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_without_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """When no serial is provided, a synthetic key p{printer_id}a{ams_id} is used."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/2",
+            json={"label": "Old Firmware AMS"},
+        )
+        assert response.status_code == 200
+
+        # Verify the synthetic key was stored
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a2"))
+        label = result.scalar_one_or_none()
+        assert label is not None
+        assert label.label == "Old Firmware AMS"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_whitespace_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Whitespace-only serial falls back to synthetic key."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Whitespace Test", "ams_serial": "   "},
+        )
+        assert response.status_code == 200
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
+        label = result.scalar_one_or_none()
+        assert label is not None
+        assert label.label == "Whitespace Test"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_updates_existing(self, async_client: AsyncClient, printer_factory):
+        """Saving a label with the same serial updates the existing record."""
+        printer = await printer_factory()
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Original Name", "ams_serial": "SN123"},
+        )
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Updated Name", "ams_serial": "SN123"},
+        )
+        assert response.status_code == 200
+        assert response.json()["label"] == "Updated Name"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_printer_not_found(self, async_client: AsyncClient):
+        """Returns 404 when printer does not exist."""
+        response = await async_client.put(
+            "/api/v1/printers/99999/ams-labels/0",
+            json={"label": "Ghost Printer"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_save_label_validation_empty_label(self, async_client: AsyncClient, printer_factory):
+        """Rejects empty label."""
+        printer = await printer_factory()
+        response = await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": ""},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_resolves_serial_to_ams_id(self, async_client: AsyncClient, printer_factory):
+        """GET returns labels keyed by ams_id, resolved from live printer state."""
+        printer = await printer_factory()
+
+        # Save a label with a known serial
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Silk Colours", "ams_serial": "AMS_SERIAL_0"},
+        )
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = self._mock_printer_state()
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data.get("0") == "Silk Colours"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_labels_no_printer_state(self, async_client: AsyncClient, printer_factory):
+        """GET returns empty when printer has no live state."""
+        printer = await printer_factory()
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status.return_value = None
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+        assert response.status_code == 200
+        assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_label(self, async_client: AsyncClient, printer_factory, db_session):
+        """Delete removes the label from the database."""
+        printer = await printer_factory()
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "To Delete", "ams_serial": "DEL_SN"},
+        )
+
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=DEL_SN")
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+
+        # Verify it's gone
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == "DEL_SN"))
+        assert result.scalar_one_or_none() is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_label_succeeds(self, async_client: AsyncClient, printer_factory):
+        """Delete returns success even if no label exists (idempotent)."""
+        printer = await printer_factory()
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=NONEXISTENT")
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_label_whitespace_serial_uses_synthetic_key(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Delete with whitespace serial falls back to synthetic key."""
+        printer = await printer_factory()
+        # Save with synthetic key
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0",
+            json={"label": "Synthetic Label"},
+        )
+
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=%20%20")
+        assert response.status_code == 200
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f"p{printer.id}a0"))
+        assert result.scalar_one_or_none() is None

+ 88 - 0
frontend/src/__tests__/components/FilamentSlotCircle.test.tsx

@@ -0,0 +1,88 @@
+/**
+ * Tests for the FilamentSlotCircle component.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { FilamentSlotCircle } from '../../components/FilamentSlotCircle';
+
+/**
+ * JSDOM normalizes some CSS color values (e.g. #000 → rgb(0, 0, 0)),
+ * so we compare against both hex and rgb forms.
+ */
+function expectColor(actual: string, hex: string, rgb: string) {
+  expect([hex, rgb]).toContain(actual);
+}
+
+describe('FilamentSlotCircle', () => {
+  it('renders the slot number', () => {
+    render(<FilamentSlotCircle trayColor="FF0000" trayType="PLA" isEmpty={false} slotNumber={1} />);
+    expect(screen.getByText('1')).toBeInTheDocument();
+  });
+
+  it('renders slot number for empty slot', () => {
+    render(<FilamentSlotCircle isEmpty={true} slotNumber={3} />);
+    expect(screen.getByText('3')).toBeInTheDocument();
+  });
+
+  it('uses dashed border for empty slots', () => {
+    const { container } = render(
+      <FilamentSlotCircle isEmpty={true} slotNumber={1} />
+    );
+    const circle = container.firstChild as HTMLElement;
+    expect(circle.style.borderStyle).toBe('dashed');
+  });
+
+  it('uses solid border for filled slots', () => {
+    const { container } = render(
+      <FilamentSlotCircle trayColor="FF0000" isEmpty={false} slotNumber={1} />
+    );
+    const circle = container.firstChild as HTMLElement;
+    expect(circle.style.borderStyle).toBe('solid');
+  });
+
+  it('sets background color from trayColor', () => {
+    const { container } = render(
+      <FilamentSlotCircle trayColor="00FF00" trayType="PLA" isEmpty={false} slotNumber={2} />
+    );
+    const circle = container.firstChild as HTMLElement;
+    expectColor(circle.style.backgroundColor, '#00FF00', 'rgb(0, 255, 0)');
+  });
+
+  it('uses dark background when trayType is set but no color', () => {
+    const { container } = render(
+      <FilamentSlotCircle trayType="PLA" isEmpty={false} slotNumber={1} />
+    );
+    const circle = container.firstChild as HTMLElement;
+    expectColor(circle.style.backgroundColor, '#333', 'rgb(51, 51, 51)');
+  });
+
+  it('uses transparent background when empty and no type', () => {
+    const { container } = render(
+      <FilamentSlotCircle isEmpty={true} slotNumber={1} />
+    );
+    const circle = container.firstChild as HTMLElement;
+    expect(circle.style.backgroundColor).toBe('transparent');
+  });
+
+  it('uses black text on light filament colors', () => {
+    // White filament (FFFFFF) is light
+    render(<FilamentSlotCircle trayColor="FFFFFF" isEmpty={false} slotNumber={1} />);
+    const text = screen.getByText('1');
+    expectColor(text.style.color, '#000', 'rgb(0, 0, 0)');
+  });
+
+  it('uses white text on dark filament colors', () => {
+    // Black filament (000000) is dark
+    render(<FilamentSlotCircle trayColor="000000" isEmpty={false} slotNumber={1} />);
+    const text = screen.getByText('1');
+    expectColor(text.style.color, '#fff', 'rgb(255, 255, 255)');
+  });
+
+  it('uses white text when no tray color', () => {
+    render(<FilamentSlotCircle isEmpty={true} slotNumber={1} />);
+    const text = screen.getByText('1');
+    expectColor(text.style.color, '#fff', 'rgb(255, 255, 255)');
+  });
+});

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DN71wENb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DOJtH8DG.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-CCwkKpMN.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CD4IeU9s.css">
+    <script type="module" crossorigin src="/assets/index-DN71wENb.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DOJtH8DG.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff