Browse Source

Add SpoolBuddy backend + frontend test coverage

  21 backend integration tests for all 12 SpoolBuddy API endpoints
  (device register/re-register/list, heartbeat status/commands/404/
  offline broadcast, NFC tag match/unmatch/removal, scale reading/
  weight calculation/missing spool, calibration tare/set-tare/
  set-factor/zero-delta/get) and 20 frontend component tests for
  WeightDisplay, SpoolInfoCard, UnknownTagCard, and TagDetectedModal.
maziggy 2 months ago
parent
commit
c2326b4fcb

+ 1 - 0
CHANGELOG.md

@@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
 - **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
 - **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
+- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
 
 ## [0.2.1] - 2026-02-27
 

+ 472 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -0,0 +1,472 @@
+"""Integration tests for SpoolBuddy API endpoints."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+def device_factory(db_session: AsyncSession):
+    """Factory to create SpoolBuddyDevice records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        n = _counter[0]
+        defaults = {
+            "device_id": f"sb-{n:04d}",
+            "hostname": f"spoolbuddy-{n}",
+            "ip_address": f"10.0.0.{n}",
+            "firmware_version": "1.0.0",
+            "has_nfc": True,
+            "has_scale": True,
+            "tare_offset": 0,
+            "calibration_factor": 1.0,
+            "last_seen": datetime.now(timezone.utc),
+        }
+        defaults.update(kwargs)
+        device = SpoolBuddyDevice(**defaults)
+        db_session.add(device)
+        await db_session.commit()
+        await db_session.refresh(device)
+        return device
+
+    return _create
+
+
+@pytest.fixture
+def spool_factory(db_session: AsyncSession):
+    """Factory to create Spool records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Polymaker",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+# ============================================================================
+# Device endpoints
+# ============================================================================
+
+
+class TestDeviceEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_register_new_device(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-new",
+                    "hostname": "spoolbuddy-new",
+                    "ip_address": "10.0.0.99",
+                    "firmware_version": "1.2.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["device_id"] == "sb-new"
+        assert data["hostname"] == "spoolbuddy-new"
+        assert data["online"] is True
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(
+            device_id="sb-exist",
+            tare_offset=12345,
+            calibration_factor=0.0042,
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-exist",
+                    "hostname": "updated-host",
+                    "ip_address": "10.0.0.200",
+                    "firmware_version": "2.0.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["id"] == device.id
+        assert data["hostname"] == "updated-host"
+        assert data["ip_address"] == "10.0.0.200"
+        assert data["firmware_version"] == "2.0.0"
+        # Calibration preserved on re-register
+        assert data["tare_offset"] == 12345
+        assert data["calibration_factor"] == pytest.approx(0.0042)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices_empty(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-a", hostname="alpha")
+        await device_factory(device_id="sb-b", hostname="beta")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        devices = resp.json()
+        assert len(devices) == 2
+        hostnames = {d["hostname"] for d in devices}
+        assert hostnames == {"alpha", "beta"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-hb")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == device.tare_offset
+        assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-cmd", pending_command="tare")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert resp.status_code == 200
+        assert resp.json()["pending_command"] == "tare"
+
+        # Second heartbeat should have no pending command (cleared)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+
+        assert resp2.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/nonexistent/heartbeat",
+                json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
+            )
+
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
+        # Create device with last_seen far in the past (offline)
+        await device_factory(
+            device_id="sb-offline",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-offline/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
+            )
+
+        assert resp.status_code == 200
+        # Should broadcast online since device was offline
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-offline"
+
+
+# ============================================================================
+# NFC endpoints
+# ============================================================================
+
+
+class TestNfcEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tag_uid="AABB1122", material="PLA")
+        mock_spool = MagicMock()
+        mock_spool.id = spool.id
+        mock_spool.material = spool.material
+        mock_spool.subtype = spool.subtype
+        mock_spool.color_name = spool.color_name
+        mock_spool.rgba = spool.rgba
+        mock_spool.brand = spool.brand
+        mock_spool.label_weight = spool.label_weight
+        mock_spool.core_weight = spool.core_weight
+        mock_spool.weight_used = spool.weight_used
+
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = mock_spool
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is True
+        assert data["spool_id"] == spool.id
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = None
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is False
+        assert data["spool_id"] is None
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_removed(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-removed",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_removed"
+        assert msg["device_id"] == "sb-1"
+        assert msg["tag_uid"] == "AABB1122"
+
+
+# ============================================================================
+# Scale endpoints
+# ============================================================================
+
+
+class TestScaleEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scale_reading_broadcast(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/scale/reading",
+                json={
+                    "device_id": "sb-1",
+                    "weight_grams": 823.5,
+                    "stable": True,
+                    "raw_adc": 456789,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_weight"
+        assert msg["device_id"] == "sb-1"
+        assert msg["weight_grams"] == 823.5
+        assert msg["stable"] is True
+        assert msg["raw_adc"] == 456789
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 750g
+        # net_filament = max(0, 750 - 250) = 500
+        # weight_used = max(0, 1000 - 500) = 500
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 500
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 1250g (full spool)
+        # net_filament = max(0, 1250 - 250) = 1000
+        # weight_used = max(0, 1000 - 1000) = 0
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 1250},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": 99999, "weight_grams": 500},
+        )
+        assert resp.status_code == 404
+
+
+# ============================================================================
+# Calibration endpoints
+# ============================================================================
+
+
+class TestCalibrationEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-tare")
+
+        resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify pending_command via heartbeat
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-tare/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
+            )
+        assert hb.json()["pending_command"] == "tare"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-st", calibration_factor=0.005)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-st/calibration/set-tare",
+            json={"tare_offset": 54321},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 54321
+        assert data["calibration_factor"] == pytest.approx(0.005)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
+        # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
+        await device_factory(device_id="sb-cf", tare_offset=10000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-cf/calibration/set-factor",
+            json={"known_weight_grams": 200, "raw_adc": 50000},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["calibration_factor"] == pytest.approx(0.005)
+        assert data["tare_offset"] == 10000
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
+        # raw_adc == tare_offset → delta is 0 → 400 error
+        await device_factory(device_id="sb-zero", tare_offset=5000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-zero/calibration/set-factor",
+            json={"known_weight_grams": 100, "raw_adc": 5000},
+        )
+
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_calibration(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-gcal",
+            tare_offset=11111,
+            calibration_factor=0.0042,
+        )
+
+        resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 11111
+        assert data["calibration_factor"] == pytest.approx(0.0042)

+ 116 - 0
frontend/src/__tests__/components/SpoolInfoCard.test.tsx

@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 42,
+  tag_uid: 'AABBCCDD11223344',
+  material: 'PLA',
+  subtype: 'Matte',
+  color_name: 'Jade White',
+  rgba: 'E8F5E9FF',
+  brand: 'Bambu',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 200,
+};
+
+describe('SpoolInfoCard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('renders spool material, brand, color name', () => {
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    expect(screen.getByText('Jade White')).toBeInTheDocument();
+    expect(screen.getByText(/Bambu/)).toBeInTheDocument();
+    expect(screen.getByText(/PLA/)).toBeInTheDocument();
+  });
+
+  it('shows spool color circle with correct hex color', () => {
+    const { container } = render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    // SpoolIcon renders an SVG circle with fill=colorHex
+    const circle = container.querySelector('circle[fill="#E8F5E9"]');
+    expect(circle).toBeInTheDocument();
+  });
+
+  it('shows remaining weight and fill percentage', () => {
+    // scaleWeight=900g, core=250g → remaining = 900-250 = 650g
+    // fillPercent = round(650/1000 * 100) = 65%
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={900} />);
+
+    expect(screen.getByText('650g')).toBeInTheDocument();
+    expect(screen.getByText('65%')).toBeInTheDocument();
+  });
+
+  it('calls onAssignToAms when "Assign to AMS" button clicked', () => {
+    const onAssign = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={onAssign} />
+    );
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+
+  it('calls onSyncWeight when sync button clicked', async () => {
+    const onSync = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onSyncWeight={onSync} />
+    );
+
+    fireEvent.click(screen.getByText('Sync Weight'));
+
+    await waitFor(() => {
+      expect(mockUpdateSpoolWeight).toHaveBeenCalledWith(42, 800);
+    });
+  });
+
+  it('calls onClose when close button clicked', () => {
+    const onClose = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={null} onClose={onClose} />
+    );
+
+    fireEvent.click(screen.getByText('Close'));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe('UnknownTagCard', () => {
+  it('renders tag UID', () => {
+    render(<UnknownTagCard tagUid="DEADBEEF12345678" scaleWeight={null} />);
+
+    expect(screen.getByText('DEADBEEF12345678')).toBeInTheDocument();
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+  });
+
+  it('shows "Add to Inventory" button', () => {
+    const onAdd = vi.fn();
+    render(
+      <UnknownTagCard tagUid="DEADBEEF" scaleWeight={null} onAddToInventory={onAdd} />
+    );
+
+    const btn = screen.getByText('Add to Inventory');
+    expect(btn).toBeInTheDocument();
+    fireEvent.click(btn);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+  });
+});

+ 110 - 0
frontend/src/__tests__/components/TagDetectedModal.test.tsx

@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 7,
+  tag_uid: 'AA11BB22CC33DD44',
+  material: 'PETG',
+  subtype: 'HF',
+  color_name: 'Orange',
+  rgba: 'FF6600FF',
+  brand: 'Overture',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+};
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  spool: mockSpool,
+  tagUid: 'AA11BB22CC33DD44',
+  scaleWeight: 950.0,
+  weightStable: true,
+  onSyncWeight: vi.fn(),
+  onAssignToAms: vi.fn(),
+  onLinkSpool: vi.fn(),
+  onAddToInventory: vi.fn(),
+};
+
+describe('TagDetectedModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('does not render when isOpen=false', () => {
+    render(<TagDetectedModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();
+  });
+
+  it('renders known spool view when spool provided', () => {
+    render(<TagDetectedModal {...defaultProps} />);
+
+    expect(screen.getByText('Spool Detected')).toBeInTheDocument();
+    expect(screen.getByText('Orange')).toBeInTheDocument();
+    expect(screen.getByText(/Overture/)).toBeInTheDocument();
+    expect(screen.getByText(/PETG/)).toBeInTheDocument();
+  });
+
+  it('renders unknown tag view when spool is null', () => {
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        spool={null}
+        tagUid="DEADBEEF11223344"
+      />
+    );
+
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+    expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();
+  });
+
+  it('closes on Escape key', () => {
+    const onClose = vi.fn();
+    render(<TagDetectedModal {...defaultProps} onClose={onClose} />);
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows weight from scale', () => {
+    // scaleWeight=950g, core=250g → remaining = 950-250 = 700g
+    render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);
+
+    expect(screen.getByText('700g')).toBeInTheDocument();
+  });
+
+  it('shows action buttons (Assign to AMS, Sync Weight)', () => {
+    const onAssign = vi.fn();
+    const onSync = vi.fn();
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        onAssignToAms={onAssign}
+        onSyncWeight={onSync}
+      />
+    );
+
+    expect(screen.getByText('Assign to AMS')).toBeInTheDocument();
+    expect(screen.getByText('Sync Weight')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+});

+ 80 - 0
frontend/src/__tests__/components/WeightDisplay.test.tsx

@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+
+const mockTare = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    tare: (...args: unknown[]) => mockTare(...args),
+  },
+}));
+
+const defaultProps = {
+  weight: 823.4,
+  weightStable: true,
+  deviceOnline: true,
+  deviceId: 'sb-0001',
+};
+
+describe('WeightDisplay', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockTare.mockResolvedValue({ status: 'ok' });
+  });
+
+  it('renders weight value with 1 decimal place', () => {
+    render(<WeightDisplay {...defaultProps} weight={823.456} />);
+    expect(screen.getByText('823.5')).toBeInTheDocument();
+  });
+
+  it('shows green dot when stable and online', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-green-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Stable')).toBeInTheDocument();
+  });
+
+  it('shows amber dot when unstable', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-amber-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Measuring...')).toBeInTheDocument();
+  });
+
+  it('shows gray dot when offline', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} deviceOnline={false} />
+    );
+    const dot = container.querySelector('.bg-zinc-600');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('No reading')).toBeInTheDocument();
+  });
+
+  it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {
+    render(<WeightDisplay {...defaultProps} />);
+
+    const tareButton = screen.getByText('Tare');
+    fireEvent.click(tareButton);
+
+    await waitFor(() => {
+      expect(mockTare).toHaveBeenCalledWith('sb-0001');
+    });
+  });
+
+  it('tare button is disabled when no deviceId', () => {
+    render(<WeightDisplay {...defaultProps} deviceId={null} />);
+
+    const tareButton = screen.getByText('Tare');
+    expect(tareButton).toBeDisabled();
+  });
+});