Browse Source

Added Spoolbuddy frontend/backend tests

maziggy 2 months ago
parent
commit
d750e8f0e0

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

@@ -741,3 +741,273 @@ class TestCalibrationEndpoints:
         data = resp.json()
         assert data["tare_offset"] == 11111
         assert data["calibration_factor"] == pytest.approx(0.0042)
+
+
+# ============================================================================
+# Display endpoints
+# ============================================================================
+
+
+class TestDisplayEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
+
+        resp = await async_client.put(
+            f"{API}/devices/sb-disp/display",
+            json={"brightness": 75, "blank_timeout": 300},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["brightness"] == 75
+        assert data["blank_timeout"] == 300
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp-hb")
+
+        await async_client.put(
+            f"{API}/devices/sb-disp-hb/display",
+            json={"brightness": 50, "blank_timeout": 600},
+        )
+
+        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-disp-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert hb.json()["display_brightness"] == 50
+        assert hb.json()["display_blank_timeout"] == 600
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.put(
+            f"{API}/devices/ghost/display",
+            json={"brightness": 50, "blank_timeout": 60},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp-val")
+
+        resp = await async_client.put(
+            f"{API}/devices/sb-disp-val/display",
+            json={"brightness": 150, "blank_timeout": 0},
+        )
+        assert resp.status_code == 422  # Validation error: brightness > 100
+
+
+# ============================================================================
+# Update endpoints
+# ============================================================================
+
+
+class TestUpdateEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd")
+
+        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-upd/update")
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify heartbeat returns update command
+        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-upd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        # update command is NOT cleared by heartbeat (cleared by update-status)
+        assert hb.json()["pending_command"] == "update"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-upd-off",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
+        assert resp.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/update")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-dup", update_status="updating")
+
+        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-upd-dup/update")
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "already_updating"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
+
+        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-upd-st/update-status",
+                json={"status": "updating", "message": "Fetching latest code..."},
+            )
+
+        assert resp.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_update"
+        assert msg["update_status"] == "updating"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(
+                f"{API}/devices/sb-upd-done/update-status",
+                json={"status": "complete", "message": "Update complete, restarting..."},
+            )
+
+        # Heartbeat should have no pending command
+        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-upd-done/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert hb.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
+
+        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-upd-err/update-status",
+                json={"status": "error", "message": "git fetch failed: network unreachable"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["update_status"] == "error"
+        assert "git fetch failed" in msg["update_message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/devices/ghost/update-status",
+            json={"status": "updating", "message": "test"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
+        assert device["update_status"] == "complete"
+        assert device["update_message"] == "Done!"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
+        """GET /devices/{id}/update-check queries GitHub and returns version comparison."""
+        await device_factory(device_id="sb-uc", firmware_version="0.1.0")
+
+        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+
+        mock_resp = MagicMock()
+        mock_resp.status_code = 200
+        mock_resp.json.return_value = mock_releases
+        mock_resp.raise_for_status = MagicMock()
+
+        mock_client = AsyncMock()
+        mock_client.get.return_value = mock_resp
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("httpx.AsyncClient", return_value=mock_client):
+            resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["current_version"] == "0.1.0"
+        assert data["latest_version"] == "0.2.0"
+        assert data["update_available"] is True
+        assert data["release_url"] == "https://github.com/test/releases/0.2.0"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-uc2", firmware_version="0.2.0")
+
+        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+
+        mock_resp = MagicMock()
+        mock_resp.status_code = 200
+        mock_resp.json.return_value = mock_releases
+        mock_resp.raise_for_status = MagicMock()
+
+        mock_client = AsyncMock()
+        mock_client.get.return_value = mock_resp
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("httpx.AsyncClient", return_value=mock_client):
+            resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
+
+        assert resp.status_code == 200
+        assert resp.json()["update_available"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices/ghost/update-check")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-ws")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(f"{API}/devices/sb-upd-ws/update")
+
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_update"
+        assert msg["device_id"] == "sb-upd-ws"
+        assert msg["update_status"] == "pending"

+ 131 - 0
frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx

@@ -0,0 +1,131 @@
+/**
+ * Tests for AmsUnitCard component:
+ * - Renders slot circles for a 4-slot AMS
+ * - Shows slot labels (1, 2, 3, 4)
+ * - Shows fill level bars
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
+import type { AMSUnit, AMSTray } from '../../../api/client';
+
+vi.mock('../../../utils/amsHelpers', () => ({
+  getFillBarColor: (fill: number) => {
+    if (fill > 50) return '#00ae42';
+    if (fill >= 15) return '#f59e0b';
+    return '#ef4444';
+  },
+}));
+
+function makeTray(overrides: Partial<AMSTray> = {}): AMSTray {
+  return {
+    id: 0,
+    tray_color: 'FF0000FF',
+    tray_type: 'PLA',
+    tray_sub_brands: null,
+    tray_id_name: null,
+    tray_info_idx: null,
+    remain: 80,
+    k: null,
+    cali_idx: null,
+    tag_uid: null,
+    tray_uuid: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    drying_temp: null,
+    drying_time: null,
+    ...overrides,
+  };
+}
+
+function makeUnit(overrides: Partial<AMSUnit> = {}): AMSUnit {
+  return {
+    id: 0,
+    humidity: 30,
+    temp: 25,
+    is_ams_ht: false,
+    tray: [
+      makeTray({ id: 0, tray_color: 'FF0000FF', tray_type: 'PLA', remain: 80 }),
+      makeTray({ id: 1, tray_color: '00FF00FF', tray_type: 'PETG', remain: 50 }),
+      makeTray({ id: 2, tray_color: '0000FFFF', tray_type: 'ABS', remain: 10 }),
+      makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1 }),
+    ],
+    serial_number: 'AMS001',
+    sw_ver: '1.0.0',
+    dry_time: 0,
+    dry_status: 0,
+    dry_sub_status: 0,
+    ...overrides,
+  };
+}
+
+describe('AmsUnitCard', () => {
+  it('renders 4 slot positions for a regular AMS', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={null} />
+    );
+    // 4 slot numbers should be visible (1, 2, 3, 4)
+    expect(screen.getByText('1')).toBeDefined();
+    expect(screen.getByText('2')).toBeDefined();
+    expect(screen.getByText('3')).toBeDefined();
+    expect(screen.getByText('4')).toBeDefined();
+    // grid-cols-4 class should be present
+    const grid = container.querySelector('.grid-cols-4');
+    expect(grid).not.toBeNull();
+  });
+
+  it('renders AMS name in header', () => {
+    render(<AmsUnitCard unit={makeUnit({ id: 0 })} activeSlot={null} />);
+    expect(screen.getByText('AMS A')).toBeDefined();
+  });
+
+  it('shows material types for populated slots', () => {
+    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);
+    expect(screen.getByText('PLA')).toBeDefined();
+    expect(screen.getByText('PETG')).toBeDefined();
+    expect(screen.getByText('ABS')).toBeDefined();
+  });
+
+  it('shows "Empty" for empty slot', () => {
+    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);
+    expect(screen.getByText('Empty')).toBeDefined();
+  });
+
+  it('renders fill level bars for slots with filament', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={null} />
+    );
+    // Look for fill bar elements (they have style width set to fill%)
+    const fillBars = container.querySelectorAll('.h-full.rounded-full.transition-all');
+    // 3 populated slots should have fill bars (slot 4 is empty)
+    expect(fillBars.length).toBe(3);
+  });
+
+  it('renders only 1 slot for AMS-HT', () => {
+    const htUnit = makeUnit({
+      is_ams_ht: true,
+      tray: [makeTray({ id: 0, tray_type: 'PLA', remain: 90 })],
+    });
+    const { container } = render(
+      <AmsUnitCard unit={htUnit} activeSlot={null} />
+    );
+    const grid = container.querySelector('.grid-cols-1');
+    expect(grid).not.toBeNull();
+    expect(screen.getByText('1')).toBeDefined();
+  });
+
+  it('shows humidity and temperature indicators', () => {
+    render(<AmsUnitCard unit={makeUnit({ humidity: 45, temp: 30 })} activeSlot={null} />);
+    expect(screen.getByText('45%')).toBeDefined();
+  });
+
+  it('highlights active slot with ring', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={1} />
+    );
+    const activeSlot = container.querySelector('.ring-2.ring-bambu-green');
+    expect(activeSlot).not.toBeNull();
+  });
+});

+ 60 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx

@@ -0,0 +1,60 @@
+/**
+ * Tests for SpoolBuddyBottomNav component:
+ * - Renders 4 nav items (Dashboard, AMS, Write, Settings)
+ * - NavLinks have correct paths
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { SpoolBuddyBottomNav } from '../../../components/spoolbuddy/SpoolBuddyBottomNav';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+function renderNav() {
+  return render(
+    <MemoryRouter initialEntries={['/spoolbuddy']}>
+      <SpoolBuddyBottomNav />
+    </MemoryRouter>
+  );
+}
+
+describe('SpoolBuddyBottomNav', () => {
+  it('renders 4 nav items', () => {
+    renderNav();
+    expect(screen.getByText('Dashboard')).toBeDefined();
+    expect(screen.getByText('AMS')).toBeDefined();
+    expect(screen.getByText('Write')).toBeDefined();
+    expect(screen.getByText('Settings')).toBeDefined();
+  });
+
+  it('has correct link for Dashboard', () => {
+    renderNav();
+    const link = screen.getByText('Dashboard').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy');
+  });
+
+  it('has correct link for AMS', () => {
+    renderNav();
+    const link = screen.getByText('AMS').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/ams');
+  });
+
+  it('has correct link for Write', () => {
+    renderNav();
+    const link = screen.getByText('Write').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/write-tag');
+  });
+
+  it('has correct link for Settings', () => {
+    renderNav();
+    const link = screen.getByText('Settings').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/settings');
+  });
+});

+ 89 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -0,0 +1,89 @@
+/**
+ * Tests for SpoolBuddyLayout component:
+ * - Renders without crashing
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({ time_format: 'system', language: 'en' }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+vi.mock('../../../utils/date', () => ({
+  formatTimeOnly: () => '12:00',
+}));
+
+vi.mock('lucide-react', () => ({
+  WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
+}));
+
+vi.mock('../../../components/VirtualKeyboard', () => ({
+  VirtualKeyboard: () => <div data-testid="virtual-keyboard" />,
+}));
+
+function renderLayout() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy']}>
+        <Routes>
+          <Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
+            <Route index element={<div data-testid="child-page">Child</div>} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyLayout', () => {
+  it('renders without crashing', () => {
+    const { container } = renderLayout();
+    expect(container.firstChild).not.toBeNull();
+  });
+
+  it('renders the top bar with logo', () => {
+    renderLayout();
+    const img = document.querySelector('img[alt="SpoolBuddy"]');
+    expect(img).not.toBeNull();
+  });
+
+  it('renders the bottom nav', () => {
+    renderLayout();
+    const nav = document.querySelector('nav');
+    expect(nav).not.toBeNull();
+  });
+
+  it('renders the status bar', () => {
+    renderLayout();
+    // Status bar shows "System Ready" by default (device offline triggers warning later via useEffect)
+    // Just check the status bar container exists
+    const statusBar = document.querySelector('.shrink-0.h-9');
+    expect(statusBar).not.toBeNull();
+  });
+
+  it('renders child outlet content', () => {
+    renderLayout();
+    const child = document.querySelector('[data-testid="child-page"]');
+    expect(child).not.toBeNull();
+  });
+});

+ 63 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx

@@ -0,0 +1,63 @@
+/**
+ * Tests for SpoolBuddyStatusBar component:
+ * - Shows "System Ready" with green when no alert
+ * - Shows warning message with amber styling
+ * - Shows error message with red styling
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { SpoolBuddyStatusBar } from '../../../components/spoolbuddy/SpoolBuddyStatusBar';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+describe('SpoolBuddyStatusBar', () => {
+  it('shows "System Ready" when no alert', () => {
+    render(<SpoolBuddyStatusBar />);
+    expect(screen.getByText('System Ready')).toBeDefined();
+  });
+
+  it('uses green status LED when no alert', () => {
+    const { container } = render(<SpoolBuddyStatusBar />);
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-bambu-green');
+  });
+
+  it('shows warning message with amber styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'warning', message: 'Low filament' }} />
+    );
+    expect(screen.getByText('Low filament')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-amber-500');
+    // Border should also be amber
+    const bar = container.firstElementChild as HTMLElement;
+    expect(bar.className).toContain('border-amber-500');
+  });
+
+  it('shows error message with red styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'error', message: 'Connection lost' }} />
+    );
+    expect(screen.getByText('Connection lost')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-red-500');
+    const bar = container.firstElementChild as HTMLElement;
+    expect(bar.className).toContain('border-red-500');
+  });
+
+  it('shows info alert with green styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'info', message: 'Update available' }} />
+    );
+    expect(screen.getByText('Update available')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-bambu-green');
+  });
+});

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

@@ -0,0 +1,80 @@
+/**
+ * Tests for SpoolBuddyTopBar component:
+ * - Renders the logo image
+ * - Renders the printer selector
+ * - Shows backend status indicator
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SpoolBuddyTopBar } from '../../../components/spoolbuddy/SpoolBuddyTopBar';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({ time_format: 'system' }),
+  },
+}));
+
+vi.mock('../../../utils/date', () => ({
+  formatTimeOnly: () => '12:00',
+}));
+
+vi.mock('lucide-react', () => ({
+  WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
+}));
+
+function renderTopBar(deviceOnline = false) {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <SpoolBuddyTopBar
+        selectedPrinterId={null}
+        onPrinterChange={vi.fn()}
+        deviceOnline={deviceOnline}
+      />
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyTopBar', () => {
+  it('renders the logo image', () => {
+    renderTopBar();
+    const img = screen.getByAltText('SpoolBuddy');
+    expect(img).toBeDefined();
+    expect(img.getAttribute('src')).toBe('/img/spoolbuddy_logo_dark_small.png');
+  });
+
+  it('renders the printer selector', () => {
+    renderTopBar();
+    // Select element with "No printers online" fallback
+    const select = screen.getByRole('combobox');
+    expect(select).toBeDefined();
+  });
+
+  it('shows offline status when device is offline', () => {
+    renderTopBar(false);
+    expect(screen.getByText('Offline')).toBeDefined();
+    expect(screen.getByTestId('wifi-off')).toBeDefined();
+  });
+
+  it('shows backend status when device is online', () => {
+    renderTopBar(true);
+    expect(screen.getByText('Backend')).toBeDefined();
+  });
+
+  it('shows clock time', () => {
+    renderTopBar();
+    expect(screen.getByText('12:00')).toBeDefined();
+  });
+});

+ 56 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx

@@ -0,0 +1,56 @@
+/**
+ * Tests for SpoolIcon component:
+ * - Renders SVG when not empty (with correct color)
+ * - Renders dashed circle when isEmpty=true
+ * - Respects size prop
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { SpoolIcon } from '../../../components/spoolbuddy/SpoolIcon';
+
+describe('SpoolIcon', () => {
+  it('renders SVG when not empty', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} />);
+    const svg = container.querySelector('svg');
+    expect(svg).not.toBeNull();
+  });
+
+  it('renders SVG with correct color in fill', () => {
+    const { container } = render(<SpoolIcon color="#00AE42" isEmpty={false} />);
+    const circles = container.querySelectorAll('circle');
+    // First circle has the color as fill
+    expect(circles[0].getAttribute('fill')).toBe('#00AE42');
+  });
+
+  it('renders dashed circle when isEmpty=true', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={true} />);
+    // No SVG, should be a div with border-dashed
+    const svg = container.querySelector('svg');
+    expect(svg).toBeNull();
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.className).toContain('border-dashed');
+  });
+
+  it('uses default size of 32', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} />);
+    const svg = container.querySelector('svg');
+    expect(svg!.getAttribute('width')).toBe('32');
+    expect(svg!.getAttribute('height')).toBe('32');
+  });
+
+  it('respects custom size prop', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} size={64} />);
+    const svg = container.querySelector('svg');
+    expect(svg!.getAttribute('width')).toBe('64');
+    expect(svg!.getAttribute('height')).toBe('64');
+  });
+
+  it('respects custom size prop for empty spool', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={true} size={48} />);
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.style.width).toBe('48px');
+    expect(div.style.height).toBe('48px');
+  });
+});

+ 336 - 0
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -0,0 +1,336 @@
+/**
+ * Tests for useSpoolBuddyState hook:
+ * - Reducer handles all action types correctly
+ * - Computed properties (remainingWeight, netWeight) work
+ * - Window events dispatch state updates
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+
+function dispatchCustomEvent(name: string, detail: Record<string, unknown>) {
+  window.dispatchEvent(new CustomEvent(name, { detail }));
+}
+
+describe('useSpoolBuddyState', () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('starts with initial state', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+    expect(result.current.weight).toBeNull();
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBeNull();
+    expect(result.current.matchedSpool).toBeNull();
+    expect(result.current.unknownTagUid).toBeNull();
+    expect(result.current.deviceOnline).toBe(false);
+    expect(result.current.deviceId).toBeNull();
+    expect(result.current.remainingWeight).toBeNull();
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('WEIGHT_UPDATE sets weight, stable, rawAdc, deviceOnline=true', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 250.5,
+        stable: true,
+        raw_adc: 12345,
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.weight).toBe(250.5);
+    expect(result.current.weightStable).toBe(true);
+    expect(result.current.rawAdc).toBe(12345);
+    expect(result.current.deviceOnline).toBe(true);
+    expect(result.current.deviceId).toBe('dev-1');
+  });
+
+  it('WEIGHT_UPDATE handles nested data format', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        data: {
+          weight_grams: 100,
+          stable: false,
+          raw_adc: 9999,
+          device_id: 'dev-2',
+        },
+      });
+    });
+
+    expect(result.current.weight).toBe(100);
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBe(9999);
+    expect(result.current.deviceId).toBe('dev-2');
+  });
+
+  it('TAG_MATCHED sets matchedSpool and clears unknownTagUid', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First set an unknown tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+      });
+    });
+    expect(result.current.unknownTagUid).toBe('AA:BB:CC');
+
+    // Now match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 42,
+          material: 'PLA',
+          subtype: 'Silk',
+          color_name: 'Red',
+          rgba: 'FF0000FF',
+          brand: 'Bambu',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 100,
+        },
+      });
+    });
+
+    expect(result.current.matchedSpool).not.toBeNull();
+    expect(result.current.matchedSpool!.id).toBe(42);
+    expect(result.current.matchedSpool!.material).toBe('PLA');
+    expect(result.current.matchedSpool!.subtype).toBe('Silk');
+    expect(result.current.matchedSpool!.color_name).toBe('Red');
+    expect(result.current.matchedSpool!.brand).toBe('Bambu');
+    expect(result.current.matchedSpool!.label_weight).toBe(1000);
+    expect(result.current.matchedSpool!.core_weight).toBe(250);
+    expect(result.current.matchedSpool!.weight_used).toBe(100);
+    expect(result.current.unknownTagUid).toBeNull();
+  });
+
+  it('UNKNOWN_TAG sets unknownTagUid and clears matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+    expect(result.current.matchedSpool).not.toBeNull();
+
+    // Now detect unknown tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', {
+        tag_uid: 'DD:EE:FF',
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.unknownTagUid).toBe('DD:EE:FF');
+    expect(result.current.matchedSpool).toBeNull();
+  });
+
+  it('TAG_REMOVED clears both matchedSpool and unknownTagUid', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // Set a matched spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+    expect(result.current.matchedSpool).not.toBeNull();
+
+    // Remove tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.matchedSpool).toBeNull();
+    expect(result.current.unknownTagUid).toBeNull();
+  });
+
+  it('DEVICE_ONLINE sets deviceOnline=true', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+    expect(result.current.deviceOnline).toBe(false);
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-online', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.deviceOnline).toBe(true);
+    expect(result.current.deviceId).toBe('dev-1');
+  });
+
+  it('DEVICE_OFFLINE sets deviceOnline=false and clears weight/rawAdc', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First get some weight data
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 500,
+        stable: true,
+        raw_adc: 54321,
+        device_id: 'dev-1',
+      });
+    });
+    expect(result.current.weight).toBe(500);
+    expect(result.current.rawAdc).toBe(54321);
+    expect(result.current.deviceOnline).toBe(true);
+
+    // Go offline
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-offline', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.deviceOnline).toBe(false);
+    expect(result.current.weight).toBeNull();
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBeNull();
+  });
+
+  it('computes remainingWeight from matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 300,
+        },
+      });
+    });
+
+    // remainingWeight = label_weight - weight_used = 1000 - 300 = 700
+    expect(result.current.remainingWeight).toBe(700);
+  });
+
+  it('remainingWeight is clamped to 0 when weight_used exceeds label_weight', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 1200,
+        },
+      });
+    });
+
+    expect(result.current.remainingWeight).toBe(0);
+  });
+
+  it('computes netWeight from weight and matchedSpool core_weight', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // Set weight first
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 800,
+        stable: true,
+        raw_adc: 11111,
+        device_id: 'dev-1',
+      });
+    });
+
+    // Match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+
+    // netWeight = weight - core_weight = 800 - 250 = 550
+    expect(result.current.netWeight).toBe(550);
+  });
+
+  it('netWeight is null when weight is null', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('netWeight is null when no matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 800,
+        stable: true,
+        raw_adc: 11111,
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('cleans up event listeners on unmount', () => {
+    const removeSpy = vi.spyOn(window, 'removeEventListener');
+    const { unmount } = renderHook(() => useSpoolBuddyState());
+
+    unmount();
+
+    const removedEvents = removeSpy.mock.calls.map((c) => c[0]);
+    expect(removedEvents).toContain('spoolbuddy-weight');
+    expect(removedEvents).toContain('spoolbuddy-tag-matched');
+    expect(removedEvents).toContain('spoolbuddy-unknown-tag');
+    expect(removedEvents).toContain('spoolbuddy-tag-removed');
+    expect(removedEvents).toContain('spoolbuddy-online');
+    expect(removedEvents).toContain('spoolbuddy-offline');
+  });
+});

+ 147 - 0
frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx

@@ -0,0 +1,147 @@
+/**
+ * Tests for SpoolBuddyCalibrationPage:
+ * - Renders "Scale Calibration" heading
+ * - Shows current weight display
+ * - Shows Tare and Calibrate buttons
+ * - Shows "No SpoolBuddy device found" when no device
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyCalibrationPage } from '../../pages/spoolbuddy/SpoolBuddyCalibrationPage';
+
+const mockDevice = {
+  id: 1,
+  device_id: 'sb-test-001',
+  hostname: 'spoolbuddy-pi',
+  ip_address: '192.168.1.100',
+  firmware_version: '1.2.3',
+  has_nfc: true,
+  has_scale: true,
+  tare_offset: 0,
+  calibration_factor: 1.0,
+  nfc_reader_type: 'PN532',
+  nfc_connection: 'I2C',
+  display_brightness: 80,
+  display_blank_timeout: 300,
+  has_backlight: true,
+  last_calibrated_at: null,
+  last_seen: '2026-03-22T12:00:00Z',
+  pending_command: null,
+  nfc_ok: true,
+  scale_ok: true,
+  uptime_s: 3600,
+  update_status: null,
+  update_message: null,
+  online: true,
+};
+
+let mockDevices = [mockDevice];
+
+vi.mock('../../api/client', () => ({
+  spoolbuddyApi: {
+    getDevices: vi.fn(() => Promise.resolve(mockDevices)),
+    tare: vi.fn().mockResolvedValue({ status: 'ok' }),
+    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+function makeOutletContext(overrides: Record<string, unknown> = {}) {
+  return {
+    selectedPrinterId: null,
+    setSelectedPrinterId: vi.fn(),
+    sbState: {
+      weight: 250.5,
+      weightStable: true,
+      rawAdc: 12345,
+      matchedSpool: null,
+      unknownTagUid: null,
+      deviceOnline: true,
+      deviceId: 'sb-test-001',
+      remainingWeight: null,
+      netWeight: null,
+      ...(overrides.sbState as Record<string, unknown> || {}),
+    },
+    setAlert: vi.fn(),
+    displayBrightness: 100,
+    setDisplayBrightness: vi.fn(),
+    displayBlankTimeout: 0,
+    setDisplayBlankTimeout: vi.fn(),
+  };
+}
+
+function renderPage(contextOverrides: Record<string, unknown> = {}) {
+  const ctx = makeOutletContext(contextOverrides);
+  function Wrapper() {
+    return <Outlet context={ctx} />;
+  }
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy/calibration']}>
+        <Routes>
+          <Route element={<Wrapper />}>
+            <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyCalibrationPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockDevices = [mockDevice];
+  });
+
+  it('renders "Scale Calibration" heading', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Scale Calibration')).toBeDefined();
+    });
+  });
+
+  it('shows current weight display when device available', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Current weight')).toBeDefined();
+      expect(screen.getByText('250.5 g')).toBeDefined();
+    });
+  });
+
+  it('shows Tare and Calibrate buttons when device available', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Tare')).toBeDefined();
+      expect(screen.getByText('Calibrate')).toBeDefined();
+    });
+  });
+
+  it('shows "No SpoolBuddy device found" when no device', async () => {
+    mockDevices = [];
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('No SpoolBuddy device found')).toBeDefined();
+    });
+  });
+
+  it('shows back button that navigates to settings', () => {
+    renderPage();
+    // Find the back button (contains a chevron SVG)
+    const buttons = screen.getAllByRole('button');
+    // First button is the back button
+    expect(buttons.length).toBeGreaterThan(0);
+  });
+});

+ 137 - 0
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -0,0 +1,137 @@
+/**
+ * Tests for SpoolBuddyDashboard:
+ * - Shows stats bar (Spools, Materials, Brands)
+ * - Shows "Ready to scan" idle state when no tag detected
+ * - Shows device status section
+ * - Shows "Device Offline" state when device offline
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn().mockResolvedValue([
+      { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },
+      { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },
+      { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+    ]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    linkTagToSpool: vi.fn().mockResolvedValue({}),
+    createSpool: vi.fn().mockResolvedValue({ id: 4 }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: null,
+    weightStable: false,
+    rawAdc: null,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: true,
+    deviceId: 'dev-1',
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 100,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 0,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}) {
+  const ctx = {
+    ...mockOutletContext,
+    sbState: { ...mockOutletContext.sbState, ...overrides },
+  };
+  function Wrapper() {
+    return <Outlet context={ctx} />;
+  }
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy']}>
+        <Routes>
+          <Route element={<Wrapper />}>
+            <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyDashboard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('shows stats bar with spool count, materials, and brands', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Spools')).toBeDefined();
+      expect(screen.getByText('Materials')).toBeDefined();
+      expect(screen.getByText('Brands')).toBeDefined();
+      // Check that the stats numbers are rendered (3 spools, 3 materials, 2 brands)
+      const statNumbers = screen.getAllByText(/^[0-9]+$/);
+      expect(statNumbers.length).toBeGreaterThanOrEqual(3);
+    });
+  });
+
+  it('shows "Ready to scan" idle state when device online with no tag', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Ready to scan')).toBeDefined();
+      expect(screen.getByText('Place a spool on the scale to identify it')).toBeDefined();
+    });
+  });
+
+  it('shows device status section', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Device')).toBeDefined();
+    });
+  });
+
+  it('shows "Online" when device is online', async () => {
+    renderPage({ deviceOnline: true });
+    await waitFor(() => {
+      expect(screen.getByText('Online')).toBeDefined();
+    });
+  });
+
+  it('shows "Device Offline" state when device offline', async () => {
+    renderPage({ deviceOnline: false });
+    await waitFor(() => {
+      expect(screen.getByText('Device Offline')).toBeDefined();
+      expect(screen.getByText('Connect the SpoolBuddy display to scan spools')).toBeDefined();
+    });
+  });
+
+  it('shows current spool section heading', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Current Spool')).toBeDefined();
+    });
+  });
+});

+ 173 - 0
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -0,0 +1,173 @@
+/**
+ * Tests for SpoolBuddySettingsPage:
+ * - Renders 4 tabs (Device, Display, Scale, Updates)
+ * - Device tab shows hostname, IP, NFC status
+ * - Updates tab shows "Check for Updates" button
+ * - Tab switching works
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddySettingsPage } from '../../pages/spoolbuddy/SpoolBuddySettingsPage';
+
+vi.mock('../../api/client', () => ({
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([{
+      id: 1,
+      device_id: 'sb-test-001',
+      hostname: 'spoolbuddy-pi',
+      ip_address: '192.168.1.100',
+      firmware_version: '1.2.3',
+      has_nfc: true,
+      has_scale: true,
+      tare_offset: 0,
+      calibration_factor: 1.0,
+      nfc_reader_type: 'PN532',
+      nfc_connection: 'I2C',
+      display_brightness: 80,
+      display_blank_timeout: 300,
+      has_backlight: true,
+      last_calibrated_at: null,
+      last_seen: '2026-03-22T12:00:00Z',
+      pending_command: null,
+      nfc_ok: true,
+      scale_ok: true,
+      uptime_s: 3600,
+      update_status: null,
+      update_message: null,
+      online: true,
+    }]),
+    updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
+    tare: vi.fn().mockResolvedValue({ status: 'ok' }),
+    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),
+    checkDaemonUpdate: vi.fn().mockResolvedValue({
+      current_version: '1.2.3',
+      latest_version: '1.2.3',
+      update_available: false,
+    }),
+    triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: 250.0,
+    weightStable: true,
+    rawAdc: 12345,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: true,
+    deviceId: 'sb-test-001',
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 80,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 300,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function OutletWrapper() {
+  return <Outlet context={mockOutletContext} />;
+}
+
+function renderPage() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy/settings']}>
+        <Routes>
+          <Route element={<OutletWrapper />}>
+            <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddySettingsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders 4 tabs', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Device')).toBeDefined();
+      expect(screen.getByText('Display')).toBeDefined();
+      expect(screen.getByText('Scale')).toBeDefined();
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+  });
+
+  it('device tab shows hostname and IP', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('spoolbuddy-pi')).toBeDefined();
+      expect(screen.getByText('192.168.1.100')).toBeDefined();
+    });
+  });
+
+  it('device tab shows NFC reader type', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('PN532')).toBeDefined();
+    });
+  });
+
+  it('device tab shows NFC status as Ready', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Ready')).toBeDefined();
+    });
+  });
+
+  it('switching to Updates tab shows Check for Updates button', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Updates'));
+    await waitFor(() => {
+      expect(screen.getByText('Check for Updates')).toBeDefined();
+    });
+  });
+
+  it('switching to Display tab shows Brightness', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Display')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Display'));
+    await waitFor(() => {
+      expect(screen.getByText('Brightness')).toBeDefined();
+    });
+  });
+
+  it('switching to Scale tab shows Tare and Calibrate buttons', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Scale')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Scale'));
+    await waitFor(() => {
+      expect(screen.getByText('Tare')).toBeDefined();
+      expect(screen.getByText('Calibrate')).toBeDefined();
+    });
+  });
+});

+ 0 - 0
spoolbuddy/tests/__init__.py


+ 223 - 0
spoolbuddy/tests/test_api_client.py

@@ -0,0 +1,223 @@
+"""Tests for daemon.api_client — APIClient HTTP communication."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+from daemon.api_client import MAX_BUFFER_SIZE, APIClient
+
+
+@pytest.fixture
+def api():
+    return APIClient("http://localhost:5000", "test-key")
+
+
+class TestAPIClientInit:
+    def test_base_url_construction(self, api):
+        assert api._base == "http://localhost:5000/api/v1/spoolbuddy"
+
+    def test_base_url_strips_trailing_slash(self):
+        client = APIClient("http://localhost:5000/", "key")
+        assert client._base == "http://localhost:5000/api/v1/spoolbuddy"
+
+    def test_api_key_in_headers(self):
+        client = APIClient("http://localhost:5000", "my-key")
+        assert client._headers == {"X-API-Key": "my-key"}
+
+    def test_no_api_key_empty_headers(self):
+        client = APIClient("http://localhost:5000", "")
+        assert client._headers == {}
+
+
+class TestPost:
+    @pytest.mark.asyncio
+    async def test_post_success(self, api):
+        mock_resp = MagicMock()
+        mock_resp.status_code = 200
+        mock_resp.json.return_value = {"ok": True}
+        mock_resp.raise_for_status = MagicMock()
+
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        result = await api._post("/test", {"key": "value"})
+
+        assert result == {"ok": True}
+        assert api._connected is True
+        assert api._backoff == 1.0
+        api._client.post.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_post_failure_buffers_request(self, api):
+        api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+        result = await api._post("/test", {"data": 1})
+
+        assert result is None
+        assert len(api._buffer) == 1
+        assert api._buffer[0] == {"path": "/test", "data": {"data": 1}}
+
+    @pytest.mark.asyncio
+    async def test_post_failure_logs_connection_lost_once(self, api):
+        api._connected = True
+        api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+        await api._post("/a", {})
+        assert api._connected is False
+
+        # Second failure should not log "connection lost" again
+        await api._post("/b", {})
+        assert len(api._buffer) == 2
+
+    @pytest.mark.asyncio
+    async def test_post_success_resets_backoff(self, api):
+        api._backoff = 16.0
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {}
+        mock_resp.raise_for_status = MagicMock()
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        await api._post("/test", {})
+
+        assert api._backoff == 1.0
+
+    @pytest.mark.asyncio
+    async def test_buffer_max_size(self, api):
+        api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+        for i in range(MAX_BUFFER_SIZE + 20):
+            await api._post("/test", {"i": i})
+
+        assert len(api._buffer) == MAX_BUFFER_SIZE
+        # Oldest entries should have been dropped (deque maxlen behavior)
+        assert api._buffer[0]["data"]["i"] == 20
+
+
+class TestHeartbeat:
+    @pytest.mark.asyncio
+    async def test_heartbeat_posts_to_correct_path(self, api):
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {"pending_command": None}
+        mock_resp.raise_for_status = MagicMock()
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        result = await api.heartbeat(
+            device_id="dev-1",
+            nfc_ok=True,
+            scale_ok=False,
+            uptime_s=120,
+            ip_address="192.168.1.50",
+            firmware_version="0.2.2b1",
+        )
+
+        assert result == {"pending_command": None}
+        call_args = api._client.post.call_args
+        assert "/devices/dev-1/heartbeat" in call_args[0][0]
+
+    @pytest.mark.asyncio
+    async def test_heartbeat_flushes_buffer_on_success(self, api):
+        # Pre-populate buffer
+        api._buffer.append({"path": "/old", "data": {"x": 1}})
+
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {"ok": True}
+        mock_resp.raise_for_status = MagicMock()
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
+
+        # Buffer should be flushed (post called for heartbeat + 1 buffered item)
+        assert len(api._buffer) == 0
+
+    @pytest.mark.asyncio
+    async def test_heartbeat_returns_none_on_failure(self, api):
+        api._client.post = AsyncMock(side_effect=httpx.ConnectError("fail"))
+
+        result = await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
+
+        assert result is None
+
+
+class TestRegisterDevice:
+    @pytest.mark.asyncio
+    async def test_register_retries_until_success(self, api):
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {"device_id": "dev-1"}
+        mock_resp.raise_for_status = MagicMock()
+
+        # Fail twice, then succeed
+        call_count = 0
+
+        async def mock_post(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count <= 2:
+                raise httpx.ConnectError("refused")
+            return mock_resp
+
+        api._client.post = mock_post
+        # Speed up retries
+        api._backoff = 0.01
+        api._max_backoff = 0.02
+
+        result = await api.register_device(
+            device_id="dev-1",
+            hostname="test",
+            ip_address="1.2.3.4",
+        )
+
+        assert result == {"device_id": "dev-1"}
+        assert call_count == 3
+
+    @pytest.mark.asyncio
+    async def test_register_sends_all_fields(self, api):
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {"ok": True}
+        mock_resp.raise_for_status = MagicMock()
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        await api.register_device(
+            device_id="dev-1",
+            hostname="myhost",
+            ip_address="10.0.0.1",
+            firmware_version="0.2.2b1",
+            has_nfc=True,
+            has_scale=False,
+            tare_offset=100,
+            calibration_factor=1.05,
+            nfc_reader_type="PN532",
+            nfc_connection="SPI",
+            has_backlight=True,
+        )
+
+        call_args = api._client.post.call_args
+        payload = call_args[1]["json"]
+        assert payload["device_id"] == "dev-1"
+        assert payload["has_backlight"] is True
+        assert payload["calibration_factor"] == 1.05
+
+
+class TestReportUpdateStatus:
+    @pytest.mark.asyncio
+    async def test_report_update_status(self, api):
+        mock_resp = MagicMock()
+        mock_resp.json.return_value = {"ok": True}
+        mock_resp.raise_for_status = MagicMock()
+        api._client.post = AsyncMock(return_value=mock_resp)
+
+        result = await api.report_update_status("dev-1", "updating", "Fetching...")
+
+        assert result == {"ok": True}
+        call_args = api._client.post.call_args
+        assert "/devices/dev-1/update-status" in call_args[0][0]
+        payload = call_args[1]["json"]
+        assert payload["status"] == "updating"
+        assert payload["message"] == "Fetching..."
+
+    @pytest.mark.asyncio
+    async def test_report_update_status_failure_returns_none(self, api):
+        api._client.post = AsyncMock(side_effect=httpx.ConnectError("fail"))
+
+        result = await api.report_update_status("dev-1", "error", "oops")
+
+        assert result is None

+ 169 - 0
spoolbuddy/tests/test_config.py

@@ -0,0 +1,169 @@
+"""Tests for daemon.config — Config.load() and _get_mac_id()."""
+
+import pytest
+from daemon.config import Config, _get_mac_id
+
+
+class TestConfigLoad:
+    """Config.load() reads env vars and validates required fields."""
+
+    def test_load_with_all_env_vars(self, monkeypatch):
+        monkeypatch.setenv("SPOOLBUDDY_BACKEND_URL", "http://10.0.0.1:5000")
+        monkeypatch.setenv("SPOOLBUDDY_API_KEY", "test-key-123")
+        monkeypatch.setenv("SPOOLBUDDY_DEVICE_ID", "my-device")
+        monkeypatch.setenv("SPOOLBUDDY_HOSTNAME", "my-host")
+
+        cfg = Config.load()
+
+        assert cfg.backend_url == "http://10.0.0.1:5000"
+        assert cfg.api_key == "test-key-123"
+        assert cfg.device_id == "my-device"
+        assert cfg.hostname == "my-host"
+
+    def test_load_missing_backend_url_raises(self, monkeypatch):
+        monkeypatch.delenv("SPOOLBUDDY_BACKEND_URL", raising=False)
+        monkeypatch.setenv("SPOOLBUDDY_API_KEY", "key")
+
+        with pytest.raises(RuntimeError, match="SPOOLBUDDY_BACKEND_URL is required"):
+            Config.load()
+
+    def test_load_missing_api_key_raises(self, monkeypatch):
+        monkeypatch.setenv("SPOOLBUDDY_BACKEND_URL", "http://localhost:5000")
+        monkeypatch.delenv("SPOOLBUDDY_API_KEY", raising=False)
+
+        with pytest.raises(RuntimeError, match="SPOOLBUDDY_API_KEY is required"):
+            Config.load()
+
+    def test_load_both_missing_raises_backend_url_first(self, monkeypatch):
+        monkeypatch.delenv("SPOOLBUDDY_BACKEND_URL", raising=False)
+        monkeypatch.delenv("SPOOLBUDDY_API_KEY", raising=False)
+
+        with pytest.raises(RuntimeError, match="SPOOLBUDDY_BACKEND_URL"):
+            Config.load()
+
+    def test_load_defaults_device_id_from_mac(self, monkeypatch, tmp_path):
+        monkeypatch.setenv("SPOOLBUDDY_BACKEND_URL", "http://localhost:5000")
+        monkeypatch.setenv("SPOOLBUDDY_API_KEY", "key")
+        monkeypatch.delenv("SPOOLBUDDY_DEVICE_ID", raising=False)
+        monkeypatch.delenv("SPOOLBUDDY_HOSTNAME", raising=False)
+
+        # Mock /sys/class/net with a fake interface
+        net_dir = tmp_path / "sys" / "class" / "net"
+        eth0 = net_dir / "eth0"
+        eth0.mkdir(parents=True)
+        (eth0 / "address").write_text("aa:bb:cc:dd:ee:ff\n")
+
+        import daemon.config as config_mod
+
+        monkeypatch.setattr(config_mod, "_get_mac_id", lambda: "sb-aabbccddeeff")
+
+        cfg = Config.load()
+
+        assert cfg.device_id == "sb-aabbccddeeff"
+
+    def test_load_defaults_hostname_from_socket(self, monkeypatch):
+        monkeypatch.setenv("SPOOLBUDDY_BACKEND_URL", "http://localhost:5000")
+        monkeypatch.setenv("SPOOLBUDDY_API_KEY", "key")
+        monkeypatch.setenv("SPOOLBUDDY_DEVICE_ID", "dev-1")
+        monkeypatch.delenv("SPOOLBUDDY_HOSTNAME", raising=False)
+
+        cfg = Config.load()
+
+        # Should fall back to socket.gethostname()
+        import socket
+
+        assert cfg.hostname == socket.gethostname()
+
+    def test_load_default_intervals(self, monkeypatch):
+        monkeypatch.setenv("SPOOLBUDDY_BACKEND_URL", "http://localhost:5000")
+        monkeypatch.setenv("SPOOLBUDDY_API_KEY", "key")
+        monkeypatch.setenv("SPOOLBUDDY_DEVICE_ID", "dev-1")
+
+        cfg = Config.load()
+
+        assert cfg.nfc_poll_interval == 0.3
+        assert cfg.scale_read_interval == 0.1
+        assert cfg.scale_report_interval == 1.0
+        assert cfg.heartbeat_interval == 10.0
+        assert cfg.tare_offset == 0
+        assert cfg.calibration_factor == 1.0
+
+
+class TestGetMacId:
+    """_get_mac_id() reads MAC from /sys/class/net."""
+
+    def test_reads_first_non_lo_interface(self, monkeypatch, tmp_path):
+        net_dir = tmp_path / "sys" / "class" / "net"
+
+        lo = net_dir / "lo"
+        lo.mkdir(parents=True)
+        (lo / "address").write_text("00:00:00:00:00:00\n")
+
+        eth0 = net_dir / "eth0"
+        eth0.mkdir(parents=True)
+        (eth0 / "address").write_text("de:ad:be:ef:00:01\n")
+
+        from pathlib import Path
+
+        import daemon.config as config_mod
+
+        monkeypatch.setattr(
+            config_mod, "Path", lambda p: tmp_path / "sys" / "class" / "net" if p == "/sys/class/net" else Path(p)
+        )
+
+        _get_mac_id()
+
+    def test_skips_loopback(self, monkeypatch, tmp_path):
+        """lo interface is skipped even if it has a MAC — result is uuid fallback."""
+        # When only lo exists and /sys/class/net points to our tmp dir,
+        # _get_mac_id should skip lo and fall back to uuid.
+        # We test the real function by patching Path at the module level.
+        from pathlib import Path
+
+        import daemon.config as config_mod
+
+        net_dir = tmp_path / "net"
+        lo = net_dir / "lo"
+        lo.mkdir(parents=True)
+        (lo / "address").write_text("00:00:00:00:00:00\n")
+
+        real_path = Path
+
+        def fake_path(p):
+            if p == "/sys/class/net":
+                return real_path(net_dir)
+            return real_path(p)
+
+        monkeypatch.setattr(config_mod, "Path", fake_path)
+
+        result = _get_mac_id()
+        assert result.startswith("sb-")
+        assert len(result) == 15  # "sb-" + 12 hex uuid chars
+
+    def test_skips_all_zero_mac(self, monkeypatch, tmp_path):
+        """Interfaces with all-zero MAC are skipped."""
+        net_dir = tmp_path / "net"
+        eth0 = net_dir / "eth0"
+        eth0.mkdir(parents=True)
+        (eth0 / "address").write_text("00:00:00:00:00:00\n")
+
+    def test_fallback_to_uuid_when_no_interfaces(self, monkeypatch):
+        """When /sys/class/net doesn't exist, falls back to uuid."""
+        from pathlib import Path
+
+        import daemon.config as config_mod
+
+        # Make Path("/sys/class/net") point to nonexistent dir
+        real_path = Path
+
+        def fake_path(p):
+            if p == "/sys/class/net":
+                return real_path("/nonexistent/path/that/does/not/exist")
+            return real_path(p)
+
+        monkeypatch.setattr(config_mod, "Path", fake_path)
+
+        result = _get_mac_id()
+
+        assert result.startswith("sb-")
+        assert len(result) == 15  # "sb-" + 12 hex chars

+ 176 - 0
spoolbuddy/tests/test_display_control.py

@@ -0,0 +1,176 @@
+"""Tests for daemon.display_control — DisplayControl brightness and blanking."""
+
+import time
+
+import pytest
+
+
+class TestDisplayControlNoBacklight:
+    """DisplayControl behavior when no backlight is present."""
+
+    def test_no_backlight_detected(self, monkeypatch, tmp_path):
+        # Point BACKLIGHT_BASE to an empty directory (no backlight entries)
+        import daemon.display_control as dc_mod
+
+        empty_dir = tmp_path / "backlight"
+        empty_dir.mkdir()
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
+
+        dc = dc_mod.DisplayControl()
+
+        assert dc.has_backlight is False
+
+    def test_no_backlight_dir_missing(self, monkeypatch, tmp_path):
+        import daemon.display_control as dc_mod
+
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "nonexistent")
+
+        dc = dc_mod.DisplayControl()
+
+        assert dc.has_backlight is False
+
+    def test_set_brightness_noop_without_backlight(self, monkeypatch, tmp_path):
+        import daemon.display_control as dc_mod
+
+        empty_dir = tmp_path / "backlight"
+        empty_dir.mkdir()
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
+
+        dc = dc_mod.DisplayControl()
+
+        # Should not raise
+        dc.set_brightness(50)
+        dc.set_brightness(0)
+        dc.set_brightness(100)
+
+
+class TestDisplayControlWithBacklight:
+    """DisplayControl behavior with a mock sysfs backlight."""
+
+    @pytest.fixture
+    def display(self, monkeypatch, tmp_path):
+        import daemon.display_control as dc_mod
+
+        bl_dir = tmp_path / "backlight" / "rpi_backlight"
+        bl_dir.mkdir(parents=True)
+        (bl_dir / "brightness").write_text("200")
+        (bl_dir / "max_brightness").write_text("255")
+
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "backlight")
+
+        return dc_mod.DisplayControl(), bl_dir
+
+    def test_has_backlight_true(self, display):
+        dc, _ = display
+        assert dc.has_backlight is True
+
+    def test_set_brightness_100(self, display):
+        dc, bl_dir = display
+        dc.set_brightness(100)
+        assert (bl_dir / "brightness").read_text() == "255"
+
+    def test_set_brightness_0(self, display):
+        dc, bl_dir = display
+        dc.set_brightness(0)
+        assert (bl_dir / "brightness").read_text() == "0"
+
+    def test_set_brightness_50(self, display):
+        dc, bl_dir = display
+        dc.set_brightness(50)
+        value = int((bl_dir / "brightness").read_text())
+        # 50% of 255 = 127 or 128 depending on rounding
+        assert value == round(255 * 50 / 100)
+
+    def test_set_brightness_clamped_above_100(self, display):
+        dc, bl_dir = display
+        dc.set_brightness(200)
+        assert (bl_dir / "brightness").read_text() == "255"
+
+    def test_set_brightness_clamped_below_0(self, display):
+        dc, bl_dir = display
+        dc.set_brightness(-50)
+        assert (bl_dir / "brightness").read_text() == "0"
+
+    def test_max_brightness_fallback_on_missing_file(self, monkeypatch, tmp_path):
+        """If max_brightness file doesn't exist, defaults to 255."""
+        import daemon.display_control as dc_mod
+
+        bl_dir = tmp_path / "backlight" / "rpi_backlight"
+        bl_dir.mkdir(parents=True)
+        (bl_dir / "brightness").write_text("100")
+        # No max_brightness file
+
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "backlight")
+
+        dc = dc_mod.DisplayControl()
+        assert dc._max_brightness == 255
+
+
+class TestDisplayControlBlanking:
+    """Blanking logic: timeout, wake, tick."""
+
+    @pytest.fixture
+    def display(self, monkeypatch, tmp_path):
+        import daemon.display_control as dc_mod
+
+        empty_dir = tmp_path / "backlight"
+        empty_dir.mkdir()
+        monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
+
+        return dc_mod.DisplayControl()
+
+    def test_blank_timeout_default_disabled(self, display):
+        assert display._blank_timeout == 0
+
+    def test_set_blank_timeout(self, display):
+        display.set_blank_timeout(30)
+        assert display._blank_timeout == 30
+
+    def test_set_blank_timeout_negative_clamped(self, display):
+        display.set_blank_timeout(-10)
+        assert display._blank_timeout == 0
+
+    def test_tick_does_not_blank_when_disabled(self, display):
+        display.set_blank_timeout(0)
+        display.tick()
+        assert display._blanked is False
+
+    def test_tick_blanks_after_timeout(self, display, monkeypatch):
+        display.set_blank_timeout(5)
+        # Simulate idle for 10 seconds by backdating last_activity
+        display._last_activity = time.monotonic() - 10
+        display.tick()
+        assert display._blanked is True
+
+    def test_tick_does_not_blank_before_timeout(self, display):
+        display.set_blank_timeout(60)
+        display.wake()  # Reset activity
+        display.tick()
+        assert display._blanked is False
+
+    def test_wake_unblanks(self, display):
+        display.set_blank_timeout(5)
+        display._last_activity = time.monotonic() - 10
+        display.tick()
+        assert display._blanked is True
+
+        display.wake()
+        assert display._blanked is False
+
+    def test_tick_unblanks_when_timeout_disabled_while_blanked(self, display):
+        """If timeout is disabled while screen is blanked, tick should unblank."""
+        display.set_blank_timeout(5)
+        display._last_activity = time.monotonic() - 10
+        display.tick()
+        assert display._blanked is True
+
+        display.set_blank_timeout(0)
+        display.tick()
+        assert display._blanked is False
+
+    def test_wake_resets_activity_timer(self, display):
+        display.set_blank_timeout(5)
+        old_time = display._last_activity
+        time.sleep(0.01)
+        display.wake()
+        assert display._last_activity > old_time

+ 381 - 0
spoolbuddy/tests/test_main.py

@@ -0,0 +1,381 @@
+"""Tests for daemon.main — _perform_update() and heartbeat_loop command dispatch."""
+
+import asyncio
+import sys
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from daemon.config import Config
+from daemon.main import _perform_update, heartbeat_loop
+
+
+def _make_config(**overrides):
+    cfg = Config(
+        backend_url="http://localhost:5000",
+        api_key="test-key",
+        device_id="dev-1",
+        hostname="test-host",
+        heartbeat_interval=0.01,  # fast for tests
+    )
+    for k, v in overrides.items():
+        setattr(cfg, k, v)
+    return cfg
+
+
+def _make_api():
+    api = AsyncMock()
+    api.report_update_status = AsyncMock(return_value={"ok": True})
+    api.heartbeat = AsyncMock(return_value=None)
+    api.update_tare = AsyncMock(return_value={"ok": True})
+    return api
+
+
+def _mock_process(returncode=0, stdout=b"", stderr=b""):
+    proc = AsyncMock()
+    proc.communicate = AsyncMock(return_value=(stdout, stderr))
+    proc.returncode = returncode
+    return proc
+
+
+class TestPerformUpdate:
+    @pytest.mark.asyncio
+    async def test_successful_update(self):
+        config = _make_config()
+        api = _make_api()
+
+        proc_ok = _mock_process(returncode=0)
+
+        with (
+            patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_ok),
+            patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
+            patch("daemon.main.Path") as mock_path_cls,
+            pytest.raises(SystemExit) as exc_info,
+        ):
+            # Make venv pip not exist so it uses sys.executable path
+            mock_path_inst = MagicMock()
+            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
+            mock_path_inst.__truediv__ = MagicMock(
+                side_effect=lambda x: MagicMock(
+                    exists=MagicMock(return_value=False),
+                    __truediv__=MagicMock(return_value=MagicMock(exists=MagicMock(return_value=False))),
+                    __str__=MagicMock(return_value="/fake/repo"),
+                )
+            )
+            mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
+
+            await _perform_update(config, api)
+
+        assert exc_info.value.code == 0
+
+        # Should have reported status multiple times
+        assert api.report_update_status.await_count >= 3
+        # Last call should be "complete"
+        last_call = api.report_update_status.call_args_list[-1]
+        assert last_call[0][1] == "complete"
+
+    @pytest.mark.asyncio
+    async def test_git_fetch_failure(self):
+        config = _make_config()
+        api = _make_api()
+
+        proc_fail = _mock_process(returncode=1, stderr=b"fatal: cannot fetch")
+
+        with (
+            patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_fail),
+            patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
+            patch("daemon.main.Path") as mock_path_cls,
+        ):
+            mock_path_inst = MagicMock()
+            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
+            mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
+
+            await _perform_update(config, api)
+
+        # Should report error status
+        error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
+        assert len(error_calls) == 1
+        assert "git fetch failed" in error_calls[0][0][2]
+
+    @pytest.mark.asyncio
+    async def test_git_reset_failure(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_exec(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                # git fetch succeeds
+                return _mock_process(returncode=0)
+            else:
+                # git reset fails
+                return _mock_process(returncode=1, stderr=b"reset error")
+
+        with (
+            patch("daemon.main.asyncio.create_subprocess_exec", side_effect=mock_exec),
+            patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
+            patch("daemon.main.Path") as mock_path_cls,
+        ):
+            mock_path_inst = MagicMock()
+            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
+            mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
+
+            await _perform_update(config, api)
+
+        error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
+        assert len(error_calls) == 1
+        assert "git reset failed" in error_calls[0][0][2]
+
+
+class TestHeartbeatLoopCommands:
+    """Test command dispatch in heartbeat_loop."""
+
+    @pytest.mark.asyncio
+    async def test_update_command_triggers_perform_update(self):
+        config = _make_config()
+        api = _make_api()
+
+        # First heartbeat returns update command, second returns None to break
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {"pending_command": "update"}
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        display = MagicMock()
+        display.set_brightness = MagicMock()
+        display.set_blank_timeout = MagicMock()
+        display.tick = MagicMock()
+
+        shared = {"nfc": None, "scale": None, "display": display}
+
+        with patch("daemon.main._perform_update", new_callable=AsyncMock) as mock_update:
+            # Run for 2 iterations then cancel
+            task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+            await asyncio.sleep(0.1)
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+            mock_update.assert_awaited_once_with(config, api)
+
+    @pytest.mark.asyncio
+    async def test_update_command_reports_error_on_exception(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {"pending_command": "update"}
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        display = MagicMock()
+        display.tick = MagicMock()
+        shared = {"nfc": None, "scale": None, "display": display}
+
+        with patch("daemon.main._perform_update", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
+            task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+            await asyncio.sleep(0.1)
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+            api.report_update_status.assert_awaited()
+            error_call = api.report_update_status.call_args
+            assert error_call[0][1] == "error"
+
+    @pytest.mark.asyncio
+    async def test_tare_command_executes_scale_tare(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {"pending_command": "tare"}
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        scale = MagicMock()
+        scale.ok = True
+        scale.tare = MagicMock(return_value=512)
+
+        display = MagicMock()
+        display.tick = MagicMock()
+        shared = {"nfc": None, "scale": scale, "display": display}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        scale.tare.assert_called_once()
+        api.update_tare.assert_awaited_once_with("dev-1", 512)
+        assert config.tare_offset == 512
+
+    @pytest.mark.asyncio
+    async def test_tare_command_no_scale_logs_warning(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {"pending_command": "tare"}
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        display = MagicMock()
+        display.tick = MagicMock()
+        shared = {"nfc": None, "scale": None, "display": display}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        # Should not crash; update_tare should NOT be called
+        api.update_tare.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_write_tag_command_sets_pending_write(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {
+                    "pending_command": "write_tag",
+                    "pending_write_payload": {
+                        "spool_id": 42,
+                        "ndef_data_hex": "DEADBEEF",
+                    },
+                }
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        display = MagicMock()
+        display.tick = MagicMock()
+        display.set_brightness = MagicMock()
+        display.set_blank_timeout = MagicMock()
+        shared = {"nfc": None, "scale": None, "display": display}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        assert "pending_write" in shared
+        assert shared["pending_write"]["spool_id"] == 42
+        assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
+
+    @pytest.mark.asyncio
+    async def test_display_settings_applied_from_heartbeat(self):
+        config = _make_config()
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {
+                    "display_brightness": 75,
+                    "display_blank_timeout": 300,
+                }
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        display = MagicMock()
+        display.tick = MagicMock()
+        shared = {"nfc": None, "scale": None, "display": display}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        display.set_brightness.assert_called_with(75)
+        display.set_blank_timeout.assert_called_with(300)
+
+    @pytest.mark.asyncio
+    async def test_calibration_sync_from_heartbeat(self):
+        config = _make_config(tare_offset=0, calibration_factor=1.0)
+        api = _make_api()
+
+        call_count = 0
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return {
+                    "tare_offset": 200,
+                    "calibration_factor": 1.05,
+                }
+            return None
+
+        api.heartbeat = mock_heartbeat
+
+        scale = MagicMock()
+        scale.ok = True
+        display = MagicMock()
+        display.tick = MagicMock()
+        shared = {"nfc": None, "scale": scale, "display": display}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        assert config.tare_offset == 200
+        assert config.calibration_factor == 1.05
+        scale.update_calibration.assert_called_with(200, 1.05)

+ 79 - 0
spoolbuddy/tests/test_tag_parser.py

@@ -0,0 +1,79 @@
+"""Tests for daemon.tag_parser — parse_bambu_blocks()."""
+
+from daemon.tag_parser import parse_bambu_blocks
+
+
+class TestParseBambuBlocks:
+    """parse_bambu_blocks() extracts metadata from MIFARE Classic blocks."""
+
+    def test_empty_dict_returns_empty(self):
+        result = parse_bambu_blocks({})
+        assert result == {}
+
+    def test_tray_uuid_from_blocks_4_and_5(self):
+        # 16 bytes per block, UUID is first 16 bytes of block4+block5
+        block4 = bytes(range(16))  # 00010203...0f
+        block5 = bytes(range(16, 32))  # 10111213...1f
+        blocks = {4: block4, 5: block5}
+
+        result = parse_bambu_blocks(blocks)
+
+        # UUID = first 16 bytes of (block4 + block5) = block4 itself
+        expected_uuid = block4.hex().upper()
+        assert result["tray_uuid"] == expected_uuid
+
+    def test_tray_uuid_missing_block_4(self):
+        blocks = {5: b"\x00" * 16}
+        result = parse_bambu_blocks(blocks)
+        assert "tray_uuid" not in result
+
+    def test_tray_uuid_missing_block_5(self):
+        blocks = {4: b"\x00" * 16}
+        result = parse_bambu_blocks(blocks)
+        assert "tray_uuid" not in result
+
+    def test_material_raw_from_block_1(self):
+        block1 = b"\x50\x4c\x41\x00\x00\x00\x00\x00" + b"\xff" * 8
+        blocks = {1: block1}
+
+        result = parse_bambu_blocks(blocks)
+
+        assert result["material_raw"] == block1[:8].hex().upper()
+
+    def test_block2_raw_from_block_2(self):
+        block2 = bytes([0xAA, 0xBB] + [0x00] * 14)
+        blocks = {2: block2}
+
+        result = parse_bambu_blocks(blocks)
+
+        assert result["block2_raw"] == block2.hex().upper()
+
+    def test_all_blocks_present(self):
+        block1 = b"\x01" * 16
+        block2 = b"\x02" * 16
+        block4 = b"\x04" * 16
+        block5 = b"\x05" * 16
+        blocks = {1: block1, 2: block2, 4: block4, 5: block5}
+
+        result = parse_bambu_blocks(blocks)
+
+        assert "tray_uuid" in result
+        assert "material_raw" in result
+        assert "block2_raw" in result
+
+    def test_extra_blocks_ignored(self):
+        """Blocks not in {1, 2, 4, 5} don't affect output."""
+        blocks = {0: b"\x00" * 16, 3: b"\x03" * 16, 6: b"\x06" * 16}
+        result = parse_bambu_blocks(blocks)
+        assert result == {}
+
+    def test_tray_uuid_hex_uppercase(self):
+        block4 = b"\xab\xcd\xef\x12\x34\x56\x78\x9a\xbc\xde\xf0\x11\x22\x33\x44\x55"
+        block5 = b"\x00" * 16
+        blocks = {4: block4, 5: block5}
+
+        result = parse_bambu_blocks(blocks)
+
+        # Verify uppercase hex
+        assert result["tray_uuid"] == result["tray_uuid"].upper()
+        assert "abcdef" not in result["tray_uuid"]  # no lowercase