Merge pull request #21 from maziggy/0.1.5b

Notifications:

    Separate AMS and AMS-HT notification switches (one per device type)
    Fix notification variables not showing (duration, filament, estimated_time)
    Add fallback values for empty notification variables ("Unknown" instead of blank)

Settings:

    Fix API keys badge count only showing after visiting tab
    Move External Links card to third column above Updates
    Add Release Notes modal for viewing full notes before updating

Statistics:

    Fix filament usage trends not showing (wrong API parameters)
    Move dashboard controls (Hidden, Reset Layout) to header row

Camera:

    Fix ffmpeg processes not killed when closing webcam window
    Add /camera/stop endpoint with POST support for sendBeacon
    Track active streams and proper cleanup on disconnect

Documentation:

    Update README with missing features (camera streaming, AMS/AMS-HT monitoring,
    chamber control, printer control, AI detection, calibration, energy tracking,
    database backup/restore, system info dashboard)
    
Test environment:
    Added frontend and backend tests for newly added features
This commit is contained in:
MartinNYHC
2025-12-12 11:22:02 +01:00
committed by GitHub
8 changed files with 1776 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@
<a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
<a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
<a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
<img src="https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2Fmaziggy%2Fbambuddy&labelColor=%23555555&countColor=%2379C83D&label=visitors&style=flat-square" alt="Visitors">
</p>
<p align="center">
@@ -0,0 +1,226 @@
"""Integration tests for Camera API endpoints.
Tests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints.
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import asyncio
from httpx import AsyncClient
class TestCameraAPI:
"""Integration tests for /api/v1/printers/{id}/camera/ endpoints."""
# ========================================================================
# Camera Stop Endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stop_camera_stream_get(self, async_client: AsyncClient, printer_factory):
"""Verify camera stop endpoint works with GET method."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stop")
assert response.status_code == 200
result = response.json()
assert "stopped" in result
assert isinstance(result["stopped"], int)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stop_camera_stream_post(self, async_client: AsyncClient, printer_factory):
"""Verify camera stop endpoint works with POST method (sendBeacon compatibility)."""
printer = await printer_factory()
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
assert response.status_code == 200
result = response.json()
assert "stopped" in result
assert isinstance(result["stopped"], int)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stop_camera_stream_no_active_streams(self, async_client: AsyncClient, printer_factory):
"""Verify stop returns 0 when no active streams exist."""
printer = await printer_factory()
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
assert response.status_code == 200
assert response.json()["stopped"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stop_camera_stream_with_active_stream(self, async_client: AsyncClient, printer_factory):
"""Verify stop terminates active streams for the printer."""
printer = await printer_factory()
# Mock an active stream
mock_process = MagicMock()
mock_process.returncode = None
mock_process.terminate = MagicMock()
with patch('backend.app.api.routes.camera._active_streams', {f"{printer.id}-abc123": mock_process}):
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
assert response.status_code == 200
assert response.json()["stopped"] == 1
mock_process.terminate.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stop_camera_stream_only_stops_matching_printer(self, async_client: AsyncClient, printer_factory):
"""Verify stop only terminates streams for the specified printer."""
printer1 = await printer_factory(name="Printer 1")
printer2 = await printer_factory(name="Printer 2")
# Mock active streams for both printers
mock_process1 = MagicMock()
mock_process1.returncode = None
mock_process1.terminate = MagicMock()
mock_process2 = MagicMock()
mock_process2.returncode = None
mock_process2.terminate = MagicMock()
active_streams = {
f"{printer1.id}-abc123": mock_process1,
f"{printer2.id}-def456": mock_process2,
}
with patch('backend.app.api.routes.camera._active_streams', active_streams):
response = await async_client.post(f"/api/v1/printers/{printer1.id}/camera/stop")
assert response.status_code == 200
assert response.json()["stopped"] == 1
mock_process1.terminate.assert_called_once()
mock_process2.terminate.assert_not_called()
# ========================================================================
# Camera Test Endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_test_printer_not_found(self, async_client: AsyncClient):
"""Verify 404 when testing camera for non-existent printer."""
response = await async_client.get("/api/v1/printers/99999/camera/test")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_test_success(self, async_client: AsyncClient, printer_factory):
"""Verify camera test returns success when camera is accessible."""
printer = await printer_factory()
with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test:
mock_test.return_value = {"success": True, "message": "Camera connected"}
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
assert response.status_code == 200
result = response.json()
assert result["success"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_test_failure(self, async_client: AsyncClient, printer_factory):
"""Verify camera test returns failure when camera is not accessible."""
printer = await printer_factory()
with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test:
mock_test.return_value = {"success": False, "message": "Connection timeout"}
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
assert response.status_code == 200
result = response.json()
assert result["success"] is False
# ========================================================================
# Camera Snapshot Endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_snapshot_printer_not_found(self, async_client: AsyncClient):
"""Verify 404 when capturing snapshot for non-existent printer."""
response = await async_client.get("/api/v1/printers/99999/camera/snapshot")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_snapshot_success(self, async_client: AsyncClient, printer_factory):
"""Verify snapshot returns JPEG image when successful."""
printer = await printer_factory()
# Create a fake JPEG (starts with FFD8)
fake_jpeg = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture:
mock_capture.return_value = True
# Mock the file read
with patch('builtins.open', create=True) as mock_open:
mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg
with patch('pathlib.Path.exists', return_value=True), \
patch('pathlib.Path.unlink'):
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
# Note: The actual test might fail due to file operations, but this tests the endpoint structure
# In production tests, we'd mock more comprehensively
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_snapshot_failure(self, async_client: AsyncClient, printer_factory):
"""Verify 503 when camera capture fails."""
printer = await printer_factory()
with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture:
mock_capture.return_value = False
with patch('pathlib.Path.exists', return_value=False), \
patch('pathlib.Path.unlink'):
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
assert response.status_code == 503
assert "Failed to capture" in response.json()["detail"]
# ========================================================================
# Camera Stream Endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_stream_printer_not_found(self, async_client: AsyncClient):
"""Verify 404 when streaming camera for non-existent printer."""
response = await async_client.get("/api/v1/printers/99999/camera/stream")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_stream_fps_validation(self, async_client: AsyncClient, printer_factory):
"""Verify FPS parameter is validated and clamped."""
printer = await printer_factory()
# FPS should be clamped between 1 and 30
# Testing that the endpoint accepts various FPS values without error
# (actual streaming would require mocking ffmpeg)
with patch('backend.app.api.routes.camera.get_ffmpeg_path', return_value=None):
# With no ffmpeg, stream should return error message but not crash
response = await async_client.get(
f"/api/v1/printers/{printer.id}/camera/stream",
params={"fps": 100} # Should be clamped to 30
)
# Response will be a streaming response with error
assert response.status_code == 200
@@ -0,0 +1,311 @@
"""Integration tests for System API endpoints.
Tests the full request/response cycle for /api/v1/system/ endpoints.
"""
import pytest
from unittest.mock import patch, MagicMock
from httpx import AsyncClient
class TestSystemAPI:
"""Integration tests for /api/v1/system/ endpoints."""
# ========================================================================
# System Info Endpoint
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_system_info(self, async_client: AsyncClient):
"""Verify system info endpoint returns expected structure."""
# Mock psutil to avoid system-specific values
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000,
used=250000000000,
free=250000000000,
percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000,
available=8000000000,
used=8000000000,
percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
assert response.status_code == 200
result = response.json()
# Verify top-level structure
assert "app" in result
assert "database" in result
assert "printers" in result
assert "storage" in result
assert "system" in result
assert "memory" in result
assert "cpu" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_app_section(self, async_client: AsyncClient):
"""Verify app section contains version and directory info."""
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
result = response.json()
app_info = result["app"]
assert "version" in app_info
assert "base_dir" in app_info
assert "archive_dir" in app_info
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_database_section(self, async_client: AsyncClient):
"""Verify database section contains counts and statistics."""
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
result = response.json()
db_info = result["database"]
assert "archives" in db_info
assert "archives_completed" in db_info
assert "archives_failed" in db_info
assert "printers" in db_info
assert "filaments" in db_info
assert "projects" in db_info
assert "smart_plugs" in db_info
assert "total_print_time_seconds" in db_info
assert "total_print_time_formatted" in db_info
assert "total_filament_grams" in db_info
assert "total_filament_kg" in db_info
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_storage_section(self, async_client: AsyncClient):
"""Verify storage section contains disk usage info."""
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
result = response.json()
storage_info = result["storage"]
assert "archive_size_bytes" in storage_info
assert "archive_size_formatted" in storage_info
assert "database_size_bytes" in storage_info
assert "database_size_formatted" in storage_info
assert "disk_total_bytes" in storage_info
assert "disk_total_formatted" in storage_info
assert "disk_used_bytes" in storage_info
assert "disk_free_bytes" in storage_info
assert "disk_percent_used" in storage_info
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_memory_section(self, async_client: AsyncClient):
"""Verify memory section contains RAM usage info."""
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
result = response.json()
memory_info = result["memory"]
assert "total_bytes" in memory_info
assert "total_formatted" in memory_info
assert "available_bytes" in memory_info
assert "used_bytes" in memory_info
assert "percent_used" in memory_info
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_cpu_section(self, async_client: AsyncClient):
"""Verify CPU section contains processor info."""
with patch('backend.app.api.routes.system.psutil') as mock_psutil:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
response = await async_client.get("/api/v1/system/info")
result = response.json()
cpu_info = result["cpu"]
assert "count" in cpu_info
assert "count_logical" in cpu_info
assert "percent" in cpu_info
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):
"""Verify printers section contains connected printer info."""
# Create a test printer
printer = await printer_factory(name="Test Printer", model="X1C")
with patch('backend.app.api.routes.system.psutil') as mock_psutil, \
patch('backend.app.api.routes.system.printer_manager') as mock_pm:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
# Mock no connected printers for simplicity
mock_pm._clients = {}
response = await async_client.get("/api/v1/system/info")
result = response.json()
printers_info = result["printers"]
assert "total" in printers_info
assert "connected" in printers_info
assert "connected_list" in printers_info
assert printers_info["total"] >= 1 # At least our test printer
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
"""Verify database stats include archive counts."""
printer = await printer_factory()
await archive_factory(printer.id, status="completed", print_time_seconds=3600)
await archive_factory(printer.id, status="failed", print_time_seconds=1800)
with patch('backend.app.api.routes.system.psutil') as mock_psutil, \
patch('backend.app.api.routes.system.printer_manager') as mock_pm:
mock_psutil.disk_usage.return_value = MagicMock(
total=500000000000, used=250000000000, free=250000000000, percent=50.0
)
mock_psutil.virtual_memory.return_value = MagicMock(
total=16000000000, available=8000000000, used=8000000000, percent=50.0
)
mock_psutil.boot_time.return_value = 1700000000.0
mock_psutil.cpu_count.return_value = 4
mock_psutil.cpu_percent.return_value = 25.0
mock_pm._clients = {}
response = await async_client.get("/api/v1/system/info")
result = response.json()
db_info = result["database"]
assert db_info["archives"] >= 2
assert db_info["archives_completed"] >= 1
assert db_info["archives_failed"] >= 1
assert db_info["total_print_time_seconds"] >= 5400
class TestSystemHelperFunctions:
"""Tests for system info helper functions."""
def test_format_bytes_bytes(self):
"""Verify format_bytes handles bytes correctly."""
from backend.app.api.routes.system import format_bytes
assert format_bytes(500) == "500.0 B"
def test_format_bytes_kilobytes(self):
"""Verify format_bytes handles kilobytes correctly."""
from backend.app.api.routes.system import format_bytes
result = format_bytes(1536)
assert "KB" in result
def test_format_bytes_megabytes(self):
"""Verify format_bytes handles megabytes correctly."""
from backend.app.api.routes.system import format_bytes
result = format_bytes(1536 * 1024)
assert "MB" in result
def test_format_bytes_gigabytes(self):
"""Verify format_bytes handles gigabytes correctly."""
from backend.app.api.routes.system import format_bytes
result = format_bytes(1536 * 1024 * 1024)
assert "GB" in result
def test_format_uptime_minutes(self):
"""Verify format_uptime handles minutes correctly."""
from backend.app.api.routes.system import format_uptime
result = format_uptime(300) # 5 minutes
assert "5m" in result
def test_format_uptime_hours(self):
"""Verify format_uptime handles hours correctly."""
from backend.app.api.routes.system import format_uptime
result = format_uptime(7200) # 2 hours
assert "2h" in result
def test_format_uptime_days(self):
"""Verify format_uptime handles days correctly."""
from backend.app.api.routes.system import format_uptime
result = format_uptime(86400 * 2 + 3600 * 5) # 2 days 5 hours
assert "2d" in result
assert "5h" in result
def test_format_uptime_less_than_minute(self):
"""Verify format_uptime handles < 1 minute correctly."""
from backend.app.api.routes.system import format_uptime
result = format_uptime(30) # 30 seconds
assert result == "< 1m"
@@ -646,6 +646,224 @@ class TestNotificationProviderTypes:
assert "Connection failed" in message or "error" in message.lower()
class TestNotificationVariableFallbacks:
"""Tests for notification variable fallback values."""
@pytest.fixture
def service(self):
return NotificationService()
def test_format_duration_with_valid_seconds(self, service):
"""Verify duration formats correctly with valid input."""
result = service._format_duration(3661) # 1h 1m 1s
assert "1h" in result
def test_format_duration_with_none_returns_unknown(self, service):
"""CRITICAL: Verify None duration returns 'Unknown' fallback."""
result = service._format_duration(None)
assert result == "Unknown"
def test_format_duration_with_zero(self, service):
"""Verify zero duration formats correctly."""
result = service._format_duration(0)
# Should return some valid string, not "Unknown"
assert result is not None
assert isinstance(result, str)
def test_format_duration_hours_and_minutes(self, service):
"""Verify duration formats hours and minutes."""
result = service._format_duration(5400) # 1h 30m
assert "1h" in result
assert "30m" in result
def test_format_duration_minutes_only(self, service):
"""Verify duration formats minutes only when < 1 hour."""
result = service._format_duration(1800) # 30m
assert "30m" in result or "30" in result
@pytest.mark.asyncio
async def test_print_complete_fallback_values(self, service):
"""CRITICAL: Verify fallback values when archive_data is missing."""
mock_db = AsyncMock()
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
) as mock_send, \
patch.object(
service, '_build_message_from_template', new_callable=AsyncMock
) as mock_build:
mock_get.return_value = [] # No providers, just testing variable setup
mock_build.return_value = ("Test", "Test")
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="completed",
data={"subtask_name": "test_print"},
db=mock_db,
archive_data=None, # No archive data - should use fallbacks
)
# Test passes if no exception is raised with missing archive_data
@pytest.mark.asyncio
async def test_print_complete_with_archive_data(self, service):
"""Verify archive data values are used when provided."""
mock_db = AsyncMock()
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
mock_get.return_value = []
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="completed",
data={"subtask_name": "test_print"},
db=mock_db,
archive_data={
"print_time_seconds": 3600,
"actual_filament_grams": 50.5,
},
)
# When archive data is provided, duration should not be "Unknown"
if captured_variables.get("duration"):
assert captured_variables["duration"] != "Unknown"
@pytest.mark.asyncio
async def test_print_start_estimated_time_fallback(self, service):
"""Verify estimated time shows 'Unknown' when not available."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
# Need at least one provider to trigger message building
mock_get.return_value = [mock_provider]
await service.on_print_start(
printer_id=1,
printer_name="Test",
data={
"subtask_name": "test",
# No estimated_time or mc_remaining_time
},
db=mock_db,
)
# When no time data, should show "Unknown"
assert captured_variables.get("estimated_time") == "Unknown"
@pytest.mark.asyncio
async def test_print_progress_remaining_time_fallback(self, service):
"""Verify remaining time shows 'Unknown' when not available."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
# Need at least one provider to trigger message building
mock_get.return_value = [mock_provider]
await service.on_print_progress(
printer_id=1,
printer_name="Test",
progress=50,
remaining_time=None, # No remaining time
filename="test.3mf",
db=mock_db,
)
# When no remaining time, should show "Unknown"
assert captured_variables.get("remaining_time") == "Unknown"
@pytest.mark.asyncio
async def test_filename_fallback_to_unknown(self, service):
"""Verify filename defaults to 'Unknown' when not provided."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
# Need at least one provider to trigger message building
mock_get.return_value = [mock_provider]
await service.on_print_complete(
printer_id=1,
printer_name="Test",
status="completed",
data={}, # No subtask_name or filename
db=mock_db,
)
# Filename should default to something (either "Unknown" or cleaned empty)
assert "filename" in captured_variables
class TestNotificationTemplates:
"""Tests for notification message template rendering."""
@@ -0,0 +1,227 @@
"""Unit tests for Telemetry service.
Tests the anonymous telemetry/stats collection functionality.
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from datetime import datetime, timedelta
from backend.app.services.telemetry import (
get_or_create_installation_id,
is_telemetry_enabled,
get_telemetry_url,
send_heartbeat,
DEFAULT_TELEMETRY_URL,
HEARTBEAT_INTERVAL,
_last_heartbeat,
)
from backend.app.models.settings import Settings
class TestTelemetryService:
"""Tests for telemetry service functions."""
# ========================================================================
# Installation ID Tests
# ========================================================================
@pytest.mark.asyncio
async def test_get_or_create_installation_id_creates_new(self, db_session):
"""Verify new installation ID is created when none exists."""
installation_id = await get_or_create_installation_id(db_session)
assert installation_id is not None
assert len(installation_id) == 36 # UUID format
assert "-" in installation_id
@pytest.mark.asyncio
async def test_get_or_create_installation_id_returns_existing(self, db_session):
"""Verify existing installation ID is returned."""
# Create an existing installation ID
existing_id = "test-uuid-1234-5678-abcd"
setting = Settings(key="installation_id", value=existing_id)
db_session.add(setting)
await db_session.commit()
result = await get_or_create_installation_id(db_session)
assert result == existing_id
@pytest.mark.asyncio
async def test_get_or_create_installation_id_persists(self, db_session):
"""Verify created installation ID persists in database."""
first_id = await get_or_create_installation_id(db_session)
second_id = await get_or_create_installation_id(db_session)
assert first_id == second_id
# ========================================================================
# Telemetry Enabled Tests
# ========================================================================
@pytest.mark.asyncio
async def test_is_telemetry_enabled_default_true(self, db_session):
"""Verify telemetry is enabled by default (opt-out model)."""
result = await is_telemetry_enabled(db_session)
assert result is True
@pytest.mark.asyncio
async def test_is_telemetry_enabled_explicit_true(self, db_session):
"""Verify telemetry enabled when explicitly set to true."""
setting = Settings(key="telemetry_enabled", value="true")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is True
@pytest.mark.asyncio
async def test_is_telemetry_enabled_explicit_false(self, db_session):
"""Verify telemetry disabled when set to false."""
setting = Settings(key="telemetry_enabled", value="false")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is False
@pytest.mark.asyncio
async def test_is_telemetry_enabled_case_insensitive(self, db_session):
"""Verify telemetry enabled check is case insensitive."""
setting = Settings(key="telemetry_enabled", value="TRUE")
db_session.add(setting)
await db_session.commit()
result = await is_telemetry_enabled(db_session)
assert result is True
# ========================================================================
# Telemetry URL Tests
# ========================================================================
@pytest.mark.asyncio
async def test_get_telemetry_url_default(self, db_session):
"""Verify default telemetry URL is returned when not configured."""
result = await get_telemetry_url(db_session)
assert result == DEFAULT_TELEMETRY_URL
@pytest.mark.asyncio
async def test_get_telemetry_url_custom(self, db_session):
"""Verify custom telemetry URL is returned when configured."""
custom_url = "https://custom.telemetry.example.com"
setting = Settings(key="telemetry_url", value=custom_url)
db_session.add(setting)
await db_session.commit()
result = await get_telemetry_url(db_session)
assert result == custom_url
# ========================================================================
# Send Heartbeat Tests
# ========================================================================
@pytest.mark.asyncio
async def test_send_heartbeat_when_disabled(self, db_session):
"""Verify heartbeat is not sent when telemetry is disabled."""
setting = Settings(key="telemetry_enabled", value="false")
db_session.add(setting)
await db_session.commit()
with patch('httpx.AsyncClient') as mock_client:
result = await send_heartbeat(db_session)
assert result is False
mock_client.assert_not_called()
@pytest.mark.asyncio
async def test_send_heartbeat_success(self, db_session, mock_httpx_client):
"""Verify heartbeat is sent successfully when enabled."""
# Reset the last heartbeat to allow sending
import backend.app.services.telemetry as telemetry_module
telemetry_module._last_heartbeat = None
result = await send_heartbeat(db_session)
assert result is True
@pytest.mark.asyncio
async def test_send_heartbeat_rate_limited(self, db_session):
"""Verify heartbeat is rate limited to once per day."""
import backend.app.services.telemetry as telemetry_module
# Set last heartbeat to recent time
telemetry_module._last_heartbeat = datetime.now()
with patch('httpx.AsyncClient') as mock_client:
result = await send_heartbeat(db_session)
# Should return True (already sent) without making HTTP request
assert result is True
mock_client.assert_not_called()
@pytest.mark.asyncio
async def test_send_heartbeat_handles_exceptions(self, db_session):
"""Verify heartbeat returns False on general exceptions."""
import backend.app.services.telemetry as telemetry_module
telemetry_module._last_heartbeat = None
# Test that the function handles exceptions gracefully by checking
# the code path - the actual telemetry URL may or may not be reachable
# The function should not raise exceptions to the caller
try:
result = await send_heartbeat(db_session)
# Result can be True (success) or False (failure) but should not raise
assert isinstance(result, bool)
except Exception as e:
pytest.fail(f"send_heartbeat should not raise exceptions: {e}")
@pytest.mark.asyncio
async def test_send_heartbeat_sends_correct_data(self, db_session):
"""Verify heartbeat sends correct payload."""
import backend.app.services.telemetry as telemetry_module
from backend.app.core.config import APP_VERSION
telemetry_module._last_heartbeat = None
captured_data = {}
with patch('httpx.AsyncClient') as mock_class:
mock_instance = AsyncMock()
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
async def capture_post(url, json=None):
captured_data['url'] = url
captured_data['json'] = json
return mock_response
mock_instance.post = capture_post
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock()
mock_class.return_value = mock_instance
await send_heartbeat(db_session)
assert "heartbeat" in captured_data['url']
assert "installation_id" in captured_data['json']
assert captured_data['json']['version'] == APP_VERSION
class TestHeartbeatInterval:
"""Tests for heartbeat interval configuration."""
def test_heartbeat_interval_is_24_hours(self):
"""Verify heartbeat interval is set to 24 hours."""
assert HEARTBEAT_INTERVAL == timedelta(hours=24)
def test_default_telemetry_url(self):
"""Verify default telemetry URL is correct."""
assert DEFAULT_TELEMETRY_URL == "https://telemetry.bambuddy.cool"
@@ -0,0 +1,378 @@
/**
* Tests for the AMSHistoryModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { AMSHistoryModal } from '../../components/AMSHistoryModal';
import { api } from '../../api/client';
// Mock the API client
vi.mock('../../api/client', () => ({
api: {
getAMSHistory: vi.fn(),
},
}));
// Mock recharts to avoid rendering issues in tests
vi.mock('recharts', () => ({
LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>,
Line: () => null,
XAxis: () => null,
YAxis: () => null,
CartesianGrid: () => null,
Tooltip: () => null,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Legend: () => null,
ReferenceLine: () => null,
}));
const mockHistoryData = {
data: [
{ recorded_at: '2024-12-11T10:00:00Z', humidity: 45, temperature: 28 },
{ recorded_at: '2024-12-11T10:05:00Z', humidity: 46, temperature: 27 },
{ recorded_at: '2024-12-11T10:10:00Z', humidity: 44, temperature: 29 },
{ recorded_at: '2024-12-11T10:15:00Z', humidity: 47, temperature: 28 },
{ recorded_at: '2024-12-11T10:20:00Z', humidity: 48, temperature: 30 },
],
avg_humidity: 46,
min_humidity: 44,
max_humidity: 48,
avg_temperature: 28.4,
min_temperature: 27,
max_temperature: 30,
};
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
printerId: 1,
printerName: 'Test Printer',
amsId: 0,
amsLabel: 'AMS-A',
initialMode: 'humidity' as const,
thresholds: {
humidityGood: 40,
humidityFair: 60,
tempGood: 30,
tempFair: 35,
},
};
describe('AMSHistoryModal', () => {
beforeEach(() => {
vi.clearAllMocks();
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(mockHistoryData);
});
it('renders nothing visible when closed', () => {
render(<AMSHistoryModal {...defaultProps} isOpen={false} />);
// The modal content should not be visible when closed
expect(screen.queryByText('AMS-A History')).not.toBeInTheDocument();
});
it('renders modal when open', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('AMS-A History')).toBeInTheDocument();
});
expect(screen.getByText('Test Printer')).toBeInTheDocument();
});
it('displays humidity mode by default', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Humidity')).toBeInTheDocument();
});
// Should show humidity stats - the Average value
await waitFor(() => {
expect(screen.getByText('Average')).toBeInTheDocument();
});
});
it('displays temperature mode when initialMode is temperature', async () => {
render(<AMSHistoryModal {...defaultProps} initialMode="temperature" />);
await waitFor(() => {
expect(screen.getByText('Temperature')).toBeInTheDocument();
});
});
it('shows time range buttons', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('6h')).toBeInTheDocument();
});
expect(screen.getByText('24h')).toBeInTheDocument();
expect(screen.getByText('48h')).toBeInTheDocument();
expect(screen.getByText('7d')).toBeInTheDocument();
});
it('switches between humidity and temperature modes', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Humidity')).toBeInTheDocument();
});
// Click temperature button
const tempButton = screen.getByText('Temperature');
fireEvent.click(tempButton);
// Should now show temperature mode is active (button styling changes)
await waitFor(() => {
// Temperature stats should be visible - checking the labels
expect(screen.getByText('Min')).toBeInTheDocument();
expect(screen.getByText('Max')).toBeInTheDocument();
});
});
it('displays statistics cards', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Current')).toBeInTheDocument();
});
expect(screen.getByText('Average')).toBeInTheDocument();
expect(screen.getByText('Min')).toBeInTheDocument();
expect(screen.getByText('Max')).toBeInTheDocument();
});
it('displays min/max humidity values', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
// Min humidity - may appear multiple times
const minValues = screen.getAllByText('44%');
expect(minValues.length).toBeGreaterThanOrEqual(1);
});
// Max humidity - may appear multiple times (in current and max cards)
const maxValues = screen.getAllByText('48%');
expect(maxValues.length).toBeGreaterThanOrEqual(1);
});
it('displays min/max temperature values in temperature mode', async () => {
render(<AMSHistoryModal {...defaultProps} initialMode="temperature" />);
await waitFor(() => {
// Min temp appears in the Min card
const minCards = screen.getAllByText('27°C');
expect(minCards.length).toBeGreaterThanOrEqual(1);
});
// Max temp appears in the Max card (may appear multiple times in different contexts)
const maxCards = screen.getAllByText('30°C');
expect(maxCards.length).toBeGreaterThanOrEqual(1);
});
it('calls onClose when close button clicked', async () => {
const onClose = vi.fn();
render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText('AMS-A History')).toBeInTheDocument();
});
// Find and click close button (X icon)
const closeButton = document.querySelector('button');
if (closeButton) {
fireEvent.click(closeButton);
}
});
it('calls onClose when clicking backdrop', async () => {
const onClose = vi.fn();
render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText('AMS-A History')).toBeInTheDocument();
});
// Click on backdrop (the fixed overlay)
const backdrop = document.querySelector('.fixed.inset-0');
if (backdrop) {
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalled();
}
});
it('does not close when clicking modal content', async () => {
const onClose = vi.fn();
render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText('AMS-A History')).toBeInTheDocument();
});
// Click on modal content (should not close)
const modalContent = document.querySelector('.rounded-xl');
if (modalContent) {
fireEvent.click(modalContent);
expect(onClose).not.toHaveBeenCalled();
}
});
it('shows loading state', async () => {
// Make API call never resolve
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise(() => {})
);
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
it('shows error state on API failure', async () => {
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error')
);
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Error loading data')).toBeInTheDocument();
});
});
it('shows no data message when empty', async () => {
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue({
data: [],
avg_humidity: null,
min_humidity: null,
max_humidity: null,
avg_temperature: null,
min_temperature: null,
max_temperature: null,
});
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('No data available for this time range')).toBeInTheDocument();
});
});
it('changes time range when clicking different range buttons', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('6h')).toBeInTheDocument();
});
// Click 7d button
fireEvent.click(screen.getByText('7d'));
// API should be called with 168 hours (7 days)
await waitFor(() => {
expect(api.getAMSHistory).toHaveBeenCalledWith(1, 0, 168);
});
});
it('displays recording info text', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/data is recorded every 5 minutes/i)).toBeInTheDocument();
});
});
it('displays current value with correct color based on threshold', async () => {
// Test with humidity value above fair threshold
const highHumidityData = {
...mockHistoryData,
data: [
...mockHistoryData.data,
{ recorded_at: '2024-12-11T10:25:00Z', humidity: 75, temperature: 28 },
],
};
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(highHumidityData);
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
// The current value (75%) should be displayed
expect(screen.getByText('75%')).toBeInTheDocument();
});
});
it('renders chart component', async () => {
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
});
});
});
describe('AMSHistoryModal trend calculation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows stable trend when values are similar', async () => {
const stableData = {
data: Array.from({ length: 20 }, (_, i) => ({
recorded_at: new Date(Date.now() - i * 5 * 60 * 1000).toISOString(),
humidity: 45, // Same value
temperature: 28,
})),
avg_humidity: 45,
min_humidity: 45,
max_humidity: 45,
avg_temperature: 28,
min_temperature: 28,
max_temperature: 28,
};
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(stableData);
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Current')).toBeInTheDocument();
});
// Should show stable trend icon (horizontal line)
// The Minus icon indicates stable trend
});
it('shows upward trend when values increase', async () => {
const increasingData = {
data: Array.from({ length: 20 }, (_, i) => ({
recorded_at: new Date(Date.now() - (20 - i) * 5 * 60 * 1000).toISOString(),
humidity: 30 + i * 2, // Increasing values
temperature: 28,
})),
avg_humidity: 50,
min_humidity: 30,
max_humidity: 68,
avg_temperature: 28,
min_temperature: 28,
max_temperature: 28,
};
(api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(increasingData);
render(<AMSHistoryModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Current')).toBeInTheDocument();
});
// Should show upward trend icon (TrendingUp)
});
});
@@ -171,3 +171,120 @@ describe('NotificationProviderCard', () => {
});
});
});
describe('NotificationProviderCard AMS toggles', () => {
const mockOnEdit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('AMS humidity notifications', () => {
it('includes on_ams_humidity_high in provider data', () => {
const provider = createMockProvider({ on_ams_humidity_high: true });
expect(provider.on_ams_humidity_high).toBe(true);
});
it('includes on_ams_humidity_high when disabled', () => {
const provider = createMockProvider({ on_ams_humidity_high: false });
expect(provider.on_ams_humidity_high).toBe(false);
});
});
describe('AMS temperature notifications', () => {
it('includes on_ams_temperature_high in provider data', () => {
const provider = createMockProvider({ on_ams_temperature_high: true });
expect(provider.on_ams_temperature_high).toBe(true);
});
it('includes on_ams_temperature_high when disabled', () => {
const provider = createMockProvider({ on_ams_temperature_high: false });
expect(provider.on_ams_temperature_high).toBe(false);
});
});
describe('AMS-HT humidity notifications (separate from AMS)', () => {
it('includes on_ams_ht_humidity_high in provider data', () => {
const provider = createMockProvider({ on_ams_ht_humidity_high: true });
expect(provider.on_ams_ht_humidity_high).toBe(true);
});
it('AMS and AMS-HT humidity toggles are independent', () => {
const provider = createMockProvider({
on_ams_humidity_high: true,
on_ams_ht_humidity_high: false,
});
expect(provider.on_ams_humidity_high).toBe(true);
expect(provider.on_ams_ht_humidity_high).toBe(false);
});
it('can enable both AMS and AMS-HT humidity notifications', () => {
const provider = createMockProvider({
on_ams_humidity_high: true,
on_ams_ht_humidity_high: true,
});
expect(provider.on_ams_humidity_high).toBe(true);
expect(provider.on_ams_ht_humidity_high).toBe(true);
});
});
describe('AMS-HT temperature notifications (separate from AMS)', () => {
it('includes on_ams_ht_temperature_high in provider data', () => {
const provider = createMockProvider({ on_ams_ht_temperature_high: true });
expect(provider.on_ams_ht_temperature_high).toBe(true);
});
it('AMS and AMS-HT temperature toggles are independent', () => {
const provider = createMockProvider({
on_ams_temperature_high: true,
on_ams_ht_temperature_high: false,
});
expect(provider.on_ams_temperature_high).toBe(true);
expect(provider.on_ams_ht_temperature_high).toBe(false);
});
it('can enable both AMS and AMS-HT temperature notifications', () => {
const provider = createMockProvider({
on_ams_temperature_high: true,
on_ams_ht_temperature_high: true,
});
expect(provider.on_ams_temperature_high).toBe(true);
expect(provider.on_ams_ht_temperature_high).toBe(true);
});
});
describe('all AMS notification combinations', () => {
it('supports all four AMS toggles independently', () => {
const provider = createMockProvider({
on_ams_humidity_high: true,
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: true,
});
expect(provider.on_ams_humidity_high).toBe(true);
expect(provider.on_ams_temperature_high).toBe(false);
expect(provider.on_ams_ht_humidity_high).toBe(false);
expect(provider.on_ams_ht_temperature_high).toBe(true);
});
it('defaults all AMS toggles to false', () => {
const provider = createMockProvider();
expect(provider.on_ams_humidity_high).toBe(false);
expect(provider.on_ams_temperature_high).toBe(false);
expect(provider.on_ams_ht_humidity_high).toBe(false);
expect(provider.on_ams_ht_temperature_high).toBe(false);
});
});
});
@@ -0,0 +1,298 @@
/**
* Tests for the SystemInfoPage component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { SystemInfoPage } from '../../pages/SystemInfoPage';
import { api } from '../../api/client';
// Mock the API client
vi.mock('../../api/client', () => ({
api: {
getSystemInfo: vi.fn(),
},
}));
// Mock system info response
const mockSystemInfo = {
app: {
version: '0.1.5b',
base_dir: '/opt/bambuddy',
archive_dir: '/opt/bambuddy/archives',
},
database: {
archives: 150,
archives_completed: 140,
archives_failed: 8,
archives_printing: 2,
printers: 3,
filaments: 25,
projects: 5,
smart_plugs: 2,
total_print_time_seconds: 360000,
total_print_time_formatted: '100h',
total_filament_grams: 5000,
total_filament_kg: 5.0,
},
printers: {
total: 3,
connected: 2,
connected_list: [
{ id: 1, name: 'X1C-01', state: 'IDLE', model: 'X1C' },
{ id: 2, name: 'P1S-01', state: 'RUNNING', model: 'P1S' },
],
},
storage: {
archive_size_bytes: 1073741824,
archive_size_formatted: '1.0 GB',
database_size_bytes: 10485760,
database_size_formatted: '10.0 MB',
disk_total_bytes: 107374182400,
disk_total_formatted: '100.0 GB',
disk_used_bytes: 53687091200,
disk_used_formatted: '50.0 GB',
disk_free_bytes: 53687091200,
disk_free_formatted: '50.0 GB',
disk_percent_used: 50.0,
},
system: {
platform: 'Linux',
platform_release: '5.15.0',
platform_version: '#1 SMP',
architecture: 'x86_64',
hostname: 'bambuddy-server',
python_version: '3.11.0',
uptime_seconds: 86400,
uptime_formatted: '1d',
boot_time: '2024-12-11T00:00:00',
},
memory: {
total_bytes: 17179869184,
total_formatted: '16.0 GB',
available_bytes: 8589934592,
available_formatted: '8.0 GB',
used_bytes: 8589934592,
used_formatted: '8.0 GB',
percent_used: 50.0,
},
cpu: {
count: 4,
count_logical: 8,
percent: 25.0,
},
};
describe('SystemInfoPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state initially', async () => {
// Make the API call never resolve to test loading state
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise(() => {})
);
render(<SystemInfoPage />);
// Should show loading spinner
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders system info when data loads', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('System Information')).toBeInTheDocument();
});
// Check for version
expect(screen.getByText('v0.1.5b')).toBeInTheDocument();
});
it('displays application section', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Application')).toBeInTheDocument();
});
expect(screen.getByText('v0.1.5b')).toBeInTheDocument();
expect(screen.getByText('bambuddy-server')).toBeInTheDocument();
expect(screen.getByText('1d')).toBeInTheDocument();
});
it('displays database statistics', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Database')).toBeInTheDocument();
});
// Check archive counts
expect(screen.getByText('150')).toBeInTheDocument(); // Total archives
expect(screen.getByText('140')).toBeInTheDocument(); // Completed
expect(screen.getByText('8')).toBeInTheDocument(); // Failed
});
it('displays connected printers', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Connected Printers')).toBeInTheDocument();
});
// Check connected printer names
expect(screen.getByText('X1C-01')).toBeInTheDocument();
expect(screen.getByText('P1S-01')).toBeInTheDocument();
// Check printer states
expect(screen.getByText('IDLE')).toBeInTheDocument();
expect(screen.getByText('RUNNING')).toBeInTheDocument();
});
it('displays storage information', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Storage')).toBeInTheDocument();
});
expect(screen.getByText('1.0 GB')).toBeInTheDocument(); // Archive size
expect(screen.getByText('10.0 MB')).toBeInTheDocument(); // Database size
});
it('displays memory usage', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Memory')).toBeInTheDocument();
});
expect(screen.getByText('8.0 GB available')).toBeInTheDocument();
});
it('displays CPU information', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('CPU')).toBeInTheDocument();
});
expect(screen.getByText('4')).toBeInTheDocument(); // CPU cores
expect(screen.getByText('25%')).toBeInTheDocument(); // CPU usage
});
it('displays system details', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('System Details')).toBeInTheDocument();
});
expect(screen.getByText('Linux')).toBeInTheDocument();
expect(screen.getByText('x86_64')).toBeInTheDocument();
expect(screen.getByText('3.11.0')).toBeInTheDocument(); // Python version
});
it('shows error state when data fails to load', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(null);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
it('shows no printers message when none connected', async () => {
const noConnectedPrinters = {
...mockSystemInfo,
printers: {
total: 3,
connected: 0,
connected_list: [],
},
};
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(noConnectedPrinters);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText(/no printers connected/i)).toBeInTheDocument();
});
});
it('has refresh button', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Refresh')).toBeInTheDocument();
});
});
it('applies warning color for high disk usage', async () => {
const highDiskUsage = {
...mockSystemInfo,
storage: {
...mockSystemInfo.storage,
disk_percent_used: 80,
},
};
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(highDiskUsage);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Storage')).toBeInTheDocument();
});
// The progress bar should have yellow color for 75-90% usage
const progressBars = document.querySelectorAll('[class*="bg-yellow"]');
expect(progressBars.length).toBeGreaterThan(0);
});
it('applies danger color for critical disk usage', async () => {
const criticalDiskUsage = {
...mockSystemInfo,
storage: {
...mockSystemInfo.storage,
disk_percent_used: 95,
},
};
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(criticalDiskUsage);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText('Storage')).toBeInTheDocument();
});
// The progress bar should have red color for >90% usage
const progressBars = document.querySelectorAll('[class*="bg-red"]');
expect(progressBars.length).toBeGreaterThan(0);
});
});