mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Added Spoolbuddy frontend/backend tests
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user