diff --git a/backend/tests/integration/test_spoolbuddy.py b/backend/tests/integration/test_spoolbuddy.py index 768f91fd0..ce6606e81 100644 --- a/backend/tests/integration/test_spoolbuddy.py +++ b/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" diff --git a/frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx b/frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx new file mode 100644 index 000000000..007e9a468 --- /dev/null +++ b/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 { + 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 { + 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( + + ); + // 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(); + expect(screen.getByText('AMS A')).toBeDefined(); + }); + + it('shows material types for populated slots', () => { + render(); + expect(screen.getByText('PLA')).toBeDefined(); + expect(screen.getByText('PETG')).toBeDefined(); + expect(screen.getByText('ABS')).toBeDefined(); + }); + + it('shows "Empty" for empty slot', () => { + render(); + expect(screen.getByText('Empty')).toBeDefined(); + }); + + it('renders fill level bars for slots with filament', () => { + const { container } = render( + + ); + // 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( + + ); + const grid = container.querySelector('.grid-cols-1'); + expect(grid).not.toBeNull(); + expect(screen.getByText('1')).toBeDefined(); + }); + + it('shows humidity and temperature indicators', () => { + render(); + expect(screen.getByText('45%')).toBeDefined(); + }); + + it('highlights active slot with ring', () => { + const { container } = render( + + ); + const activeSlot = container.querySelector('.ring-2.ring-bambu-green'); + expect(activeSlot).not.toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx new file mode 100644 index 000000000..4ed32e2eb --- /dev/null +++ b/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( + + + + ); +} + +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'); + }); +}); diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx new file mode 100644 index 000000000..84834bf29 --- /dev/null +++ b/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) => , +})); + +vi.mock('../../../components/VirtualKeyboard', () => ({ + VirtualKeyboard: () =>
, +})); + +function renderLayout() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + + }> + Child
} /> + + + + + ); +} + +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(); + }); +}); diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx new file mode 100644 index 000000000..c9ff0d208 --- /dev/null +++ b/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(); + expect(screen.getByText('System Ready')).toBeDefined(); + }); + + it('uses green status LED when no alert', () => { + const { container } = render(); + 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( + + ); + 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( + + ); + 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( + + ); + expect(screen.getByText('Update available')).toBeDefined(); + const led = container.querySelector('.rounded-full.animate-pulse'); + expect(led!.className).toContain('bg-bambu-green'); + }); +}); diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx new file mode 100644 index 000000000..b070d2817 --- /dev/null +++ b/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) => , +})); + +function renderTopBar(deviceOnline = false) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + + ); +} + +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(); + }); +}); diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx new file mode 100644 index 000000000..b48c03690 --- /dev/null +++ b/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(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('renders SVG with correct color in fill', () => { + const { container } = render(); + 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(); + // 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(); + 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(); + 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(); + const div = container.firstElementChild as HTMLElement; + expect(div.style.width).toBe('48px'); + expect(div.style.height).toBe('48px'); + }); +}); diff --git a/frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts b/frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts new file mode 100644 index 000000000..05c8acf79 --- /dev/null +++ b/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) { + 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'); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx new file mode 100644 index 000000000..8c06d0b1b --- /dev/null +++ b/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 = {}) { + 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 || {}), + }, + setAlert: vi.fn(), + displayBrightness: 100, + setDisplayBrightness: vi.fn(), + displayBlankTimeout: 0, + setDisplayBlankTimeout: vi.fn(), + }; +} + +function renderPage(contextOverrides: Record = {}) { + const ctx = makeOutletContext(contextOverrides); + function Wrapper() { + return ; + } + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + + }> + } /> + + + + + ); +} + +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); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx new file mode 100644 index 000000000..013c8c9c5 --- /dev/null +++ b/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 = {}) { + const ctx = { + ...mockOutletContext, + sbState: { ...mockOutletContext.sbState, ...overrides }, + }; + function Wrapper() { + return ; + } + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + + }> + } /> + + + + + ); +} + +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(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx new file mode 100644 index 000000000..a7418204d --- /dev/null +++ b/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 ; +} + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + + }> + } /> + + + + + ); +} + +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(); + }); + }); +}); diff --git a/spoolbuddy/tests/__init__.py b/spoolbuddy/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spoolbuddy/tests/test_api_client.py b/spoolbuddy/tests/test_api_client.py new file mode 100644 index 000000000..46cb1ac90 --- /dev/null +++ b/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 diff --git a/spoolbuddy/tests/test_config.py b/spoolbuddy/tests/test_config.py new file mode 100644 index 000000000..fb75e1189 --- /dev/null +++ b/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 diff --git a/spoolbuddy/tests/test_display_control.py b/spoolbuddy/tests/test_display_control.py new file mode 100644 index 000000000..155797c67 --- /dev/null +++ b/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 diff --git a/spoolbuddy/tests/test_main.py b/spoolbuddy/tests/test_main.py new file mode 100644 index 000000000..88952b89d --- /dev/null +++ b/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) diff --git a/spoolbuddy/tests/test_tag_parser.py b/spoolbuddy/tests/test_tag_parser.py new file mode 100644 index 000000000..bfcbad8e1 --- /dev/null +++ b/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