Added Spoolbuddy frontend/backend tests

This commit is contained in:
maziggy
2026-03-22 12:55:35 +01:00
parent cf2203914d
commit d750e8f0e0
17 changed files with 2570 additions and 0 deletions
@@ -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"
@@ -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();
});
});
@@ -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');
});
});
@@ -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();
});
});
@@ -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');
});
});
@@ -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();
});
});
@@ -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');
});
});
@@ -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');
});
});
@@ -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);
});
});
@@ -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();
});
});
});
@@ -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();
});
});
});
View File
+223
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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