feat: initial project scaffold — server, client, Android APK
- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking, notifications (progress + alerts), telemetry, and system tray .exe build - Test suite covering notifications, audio mixer, media, telemetry, and API - E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures) - Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin) - Design decision records in .pi/docs/design/ - PyInstaller build script for server .exe
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""Shared test configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure the server package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for the audio mixer data models and backend interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ..audio_mixer import (
|
||||
AudioChannel,
|
||||
AudioMixerManager,
|
||||
AudioMixerState,
|
||||
)
|
||||
|
||||
|
||||
class TestDataModels:
|
||||
def test_audio_channel_defaults(self) -> None:
|
||||
ch = AudioChannel(name="Master")
|
||||
assert ch.name == "Master"
|
||||
assert ch.volume == 0.0
|
||||
assert ch.muted == False
|
||||
assert ch.peak_left == 0.0
|
||||
|
||||
def test_audio_mixer_state_defaults(self) -> None:
|
||||
state = AudioMixerState(backend="windows")
|
||||
assert state.backend == "windows"
|
||||
assert state.master_volume == 0.0
|
||||
assert state.master_muted == False
|
||||
assert state.channels == []
|
||||
|
||||
def test_audio_mixer_state_with_channels(self) -> None:
|
||||
channels = [
|
||||
AudioChannel(name="A1", volume=0.8, muted=False),
|
||||
AudioChannel(name="B1", volume=0.5, muted=True),
|
||||
]
|
||||
state = AudioMixerState(
|
||||
backend="voicemeeter",
|
||||
master_volume=0.65,
|
||||
master_muted=False,
|
||||
channels=channels,
|
||||
)
|
||||
assert len(state.channels) == 2
|
||||
assert state.channels[0].name == "A1"
|
||||
assert state.channels[1].muted == True
|
||||
|
||||
|
||||
class TestAudioMixerManager:
|
||||
def test_init_with_no_backends(self) -> None:
|
||||
"""Manager should initialize even with no backends available."""
|
||||
manager = AudioMixerManager()
|
||||
# Should not raise
|
||||
state = manager.get_state()
|
||||
assert isinstance(state, dict)
|
||||
assert "backend" in state
|
||||
assert "master_volume" in state
|
||||
assert "channels" in state
|
||||
|
||||
def test_get_state_returns_dict(self) -> None:
|
||||
manager = AudioMixerManager()
|
||||
state = manager.get_state()
|
||||
assert isinstance(state, dict)
|
||||
assert state["backend"] in ("windows", "voicemeeter")
|
||||
assert isinstance(state["channels"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_volume_no_backends(self) -> None:
|
||||
"""set_volume should not raise when no backends are available."""
|
||||
manager = AudioMixerManager()
|
||||
await manager.set_volume("master", 0.5) # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mute_no_backends(self) -> None:
|
||||
"""set_mute should not raise when no backends are available."""
|
||||
manager = AudioMixerManager()
|
||||
await manager.set_mute("master", True) # Should not raise
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the FastAPI application endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_returns_ok(self, client: TestClient) -> None:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
class TestGetState:
|
||||
def test_get_state_returns_dict(self, client: TestClient) -> None:
|
||||
with (
|
||||
patch.object(app.state, "media", create=True),
|
||||
patch.object(app.state, "audio", create=True),
|
||||
patch.object(app.state, "notifications", create=True),
|
||||
patch.object(app.state, "telemetry", create=True),
|
||||
):
|
||||
app.state.media.get_state = lambda: {"title": "Test"}
|
||||
app.state.audio.get_state = lambda: {"backend": "windows"}
|
||||
app.state.notifications.get_active = list
|
||||
app.state.telemetry.get_state = dict
|
||||
|
||||
response = client.get("/api/v1/state")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "media" in data
|
||||
assert "audio" in data
|
||||
assert "notifications" in data
|
||||
assert "telemetry" in data
|
||||
|
||||
|
||||
class TestNotifyEndpoint:
|
||||
def test_notify_without_token_returns_401(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/notify",
|
||||
json={
|
||||
"type": "alert",
|
||||
"title": "Test",
|
||||
"message": "Hello",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_notify_with_valid_token(self, client: TestClient) -> None:
|
||||
os.environ["API_TOKEN"] = "test-token-123"
|
||||
|
||||
with patch.object(app.state, "notifications", create=True) as mock_notif:
|
||||
mock_notif.create = AsyncMock()
|
||||
mock_notif.create.return_value = type(
|
||||
"Notification", (), {"id": "test-id"}
|
||||
)()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/notify",
|
||||
json={
|
||||
"type": "alert",
|
||||
"title": "Build Complete",
|
||||
"message": "Success",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-token-123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "created"
|
||||
|
||||
# Cleanup
|
||||
del os.environ["API_TOKEN"]
|
||||
|
||||
def test_notify_with_wrong_token_returns_401(self, client: TestClient) -> None:
|
||||
os.environ["API_TOKEN"] = "correct-token"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/notify",
|
||||
json={"type": "alert", "title": "Test", "message": "Hello"},
|
||||
headers={"Authorization": "Bearer wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
del os.environ["API_TOKEN"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the media tracking module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from ..media import MediaManager, MediaState
|
||||
|
||||
|
||||
class TestMediaState:
|
||||
def test_defaults(self) -> None:
|
||||
state = MediaState()
|
||||
assert state.title == ""
|
||||
assert state.artist == ""
|
||||
assert not state.is_playing
|
||||
assert state.is_stopped
|
||||
assert state.playback_percentage == 0.0
|
||||
|
||||
def test_playing_state(self) -> None:
|
||||
state = MediaState(
|
||||
title="Test Song",
|
||||
artist="Test Artist",
|
||||
is_playing=True,
|
||||
is_paused=False,
|
||||
is_stopped=False,
|
||||
playback_percentage=45.5,
|
||||
)
|
||||
assert state.title == "Test Song"
|
||||
assert state.is_playing
|
||||
assert state.playback_percentage == 45.5
|
||||
|
||||
|
||||
class TestMediaManager:
|
||||
def test_init(self) -> None:
|
||||
manager = MediaManager()
|
||||
assert manager._state is not None
|
||||
|
||||
def test_get_state_returns_dict(self) -> None:
|
||||
manager = MediaManager()
|
||||
state = manager.get_state()
|
||||
assert isinstance(state, dict)
|
||||
assert "title" in state
|
||||
assert "artist" in state
|
||||
assert "is_playing" in state
|
||||
assert "playback_percentage" in state
|
||||
|
||||
def test_get_state_empty_when_nothing_playing(self) -> None:
|
||||
manager = MediaManager()
|
||||
state = manager.get_state()
|
||||
assert state["title"] == ""
|
||||
assert not state["is_playing"]
|
||||
assert state["is_stopped"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the notification system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ..notifications import Notification, NotificationManager, ProgressState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager() -> NotificationManager:
|
||||
return NotificationManager()
|
||||
|
||||
|
||||
class TestNotificationCreation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_alert(self, manager: NotificationManager) -> None:
|
||||
notif = Notification(
|
||||
type="alert",
|
||||
title="Build Complete",
|
||||
message="Deployment succeeded.",
|
||||
priority="info",
|
||||
)
|
||||
result = await manager.create(notif)
|
||||
assert result.id
|
||||
assert result.type == "alert"
|
||||
assert result.title == "Build Complete"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_progress(self, manager: NotificationManager) -> None:
|
||||
notif = Notification(
|
||||
type="progress",
|
||||
title="Backup",
|
||||
message="Uploading...",
|
||||
progress=ProgressState(current=50, min=0, max=100, unit="MB"),
|
||||
)
|
||||
result = await manager.create(notif)
|
||||
assert result.type == "progress"
|
||||
assert result.progress is not None
|
||||
progress = result.progress
|
||||
assert progress.percentage == 50.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_notification(
|
||||
self, manager: NotificationManager
|
||||
) -> None:
|
||||
notif = Notification(
|
||||
id="test_01",
|
||||
type="progress",
|
||||
title="Backup",
|
||||
progress=ProgressState(current=10, max=100),
|
||||
)
|
||||
await manager.create(notif)
|
||||
|
||||
notif.progress = ProgressState(current=50, max=100)
|
||||
result = await manager.create(notif)
|
||||
assert result.progress is not None
|
||||
assert result.progress.percentage == 50.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_returns_list(self, manager: NotificationManager) -> None:
|
||||
await manager.create(Notification(title="Test"))
|
||||
active = manager.get_active()
|
||||
assert len(active) == 1
|
||||
assert active[0]["title"] == "Test"
|
||||
|
||||
|
||||
class TestNotificationPruning:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prunes_old_notifications(self) -> None:
|
||||
manager = NotificationManager()
|
||||
manager._max_active = 3
|
||||
|
||||
for i in range(5):
|
||||
await manager.create(Notification(id=f"n{i}", title=f"Msg {i}"))
|
||||
|
||||
active = manager.get_active()
|
||||
assert len(active) == 3
|
||||
ids = [n["id"] for n in active]
|
||||
assert "n0" not in ids
|
||||
assert "n4" in ids
|
||||
|
||||
|
||||
class TestNotificationDismiss:
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismiss_removes_notification(
|
||||
self, manager: NotificationManager
|
||||
) -> None:
|
||||
notif = Notification(id="dismiss_me", title="Temp")
|
||||
await manager.create(notif)
|
||||
assert len(manager.get_active()) == 1
|
||||
|
||||
result = await manager.dismiss("dismiss_me")
|
||||
assert result
|
||||
assert len(manager.get_active()) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismiss_nonexistent_returns_false(
|
||||
self, manager: NotificationManager
|
||||
) -> None:
|
||||
result = await manager.dismiss("does_not_exist")
|
||||
assert not result
|
||||
|
||||
|
||||
class TestProgressState:
|
||||
def test_percentage_calculation(self) -> None:
|
||||
p = ProgressState(current=75, min=0, max=100)
|
||||
assert p.percentage == 75.0
|
||||
|
||||
def test_percentage_clamped_at_zero(self) -> None:
|
||||
p = ProgressState(current=-10, min=0, max=100)
|
||||
assert p.percentage == 0.0
|
||||
|
||||
def test_percentage_clamped_at_hundred(self) -> None:
|
||||
p = ProgressState(current=150, min=0, max=100)
|
||||
assert p.percentage == 100.0
|
||||
|
||||
def test_zero_range_returns_zero(self) -> None:
|
||||
p = ProgressState(current=50, min=0, max=0)
|
||||
assert p.percentage == 0.0
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the telemetry module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from ..telemetry import SensorReading, TelemetryManager, TelemetryState
|
||||
|
||||
|
||||
class TestSensorReading:
|
||||
def test_defaults(self) -> None:
|
||||
reading = SensorReading(name="CPU Temp", value=65.0, unit="C")
|
||||
assert reading.name == "CPU Temp"
|
||||
assert reading.value == 65.0
|
||||
assert reading.unit == "C"
|
||||
assert reading.category == ""
|
||||
|
||||
def test_with_category(self) -> None:
|
||||
reading = SensorReading(name="GPU Usage", value=72.0, unit="%", category="GPU")
|
||||
assert reading.category == "GPU"
|
||||
|
||||
|
||||
class TestTelemetryState:
|
||||
def test_defaults(self) -> None:
|
||||
state = TelemetryState()
|
||||
assert state.cpu_temp == 0.0
|
||||
assert state.gpu_temp == 0.0
|
||||
assert state.sensors == []
|
||||
|
||||
def test_with_values(self) -> None:
|
||||
state = TelemetryState(
|
||||
cpu_temp=72.5,
|
||||
gpu_temp=68.0,
|
||||
cpu_usage=45.0,
|
||||
sensors=[
|
||||
SensorReading(name="CPU", value=72.5, unit="C"),
|
||||
],
|
||||
)
|
||||
assert state.cpu_temp == 72.5
|
||||
assert len(state.sensors) == 1
|
||||
|
||||
|
||||
class TestTelemetryManager:
|
||||
def test_init(self) -> None:
|
||||
manager = TelemetryManager()
|
||||
assert manager._state is not None
|
||||
|
||||
def test_get_state_returns_dict(self) -> None:
|
||||
manager = TelemetryManager()
|
||||
state = manager.get_state()
|
||||
assert isinstance(state, dict)
|
||||
assert "cpu_temp" in state
|
||||
assert "gpu_temp" in state
|
||||
assert "sensors" in state
|
||||
|
||||
def test_get_state_empty(self) -> None:
|
||||
manager = TelemetryManager()
|
||||
state = manager.get_state()
|
||||
assert state["cpu_temp"] == 0.0
|
||||
assert state["sensors"] == []
|
||||
Reference in New Issue
Block a user