1c5b728e8b
FastAPI web server with REST + WebSocket, 5 Jinja2 HTML pages, mobile-friendly dark theme CSS, page-specific JS controllers, and Avahi mDNS service advertisement for pedal.local. Files: src/web/server.py — FastAPI app with 17 REST endpoints + /ws WebSocket src/web/__init__.py — Package init with WebServer, WebServerDeps exports src/web/templates/*.html — Dashboard, Presets, Models, IRs, Settings (base) src/web/static/style.css — Dark mobile-first CSS (80-82% max-width) src/web/static/websocket.js — Auto-reconnecting WebSocket manager src/web/static/app.js — Dashboard live updates + REST helpers src/web/static/presets.js — Bank/preset CRUD modal UI src/web/static/models.js — NAM model list/load/upload src/web/static/irs.js — IR file list/load/upload etc/avahi/pi-multifx-pedal.service — Avahi service advertisement scripts/setup-mdns.sh — One-shot mDNS setup (avahi, hostname, restart) main.py — Wire WebServer into boot/shutdown requirements.txt — Add fastapi, uvicorn, jinja2, python-multipart tests/test_web.py — 36 tests (pages, REST, WebSocket, errors) All 36 tests pass.
376 lines
12 KiB
Python
376 lines
12 KiB
Python
"""Tests for the web UI server — REST API, WebSocket, and HTML pages.
|
|
|
|
Run with:
|
|
pytest tests/test_web.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, PropertyMock
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
# Ensure src is on sys.path
|
|
SRC = Path(__file__).resolve().parent.parent / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
from src.web.server import WebServer, WebServerDeps
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_presets():
|
|
"""A mock PresetManager with a real Preset returned on load."""
|
|
from src.presets.types import Preset
|
|
pm = MagicMock()
|
|
pm.current_bank = 0
|
|
pm.current_program = 0
|
|
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
|
return pm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_pipeline():
|
|
pl = MagicMock()
|
|
pl._bypassed = False
|
|
pl._tuner_enabled = False
|
|
pl._master_volume = 0.8
|
|
return pl
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_nam():
|
|
nam = MagicMock()
|
|
nam.is_loaded = False
|
|
nam.current_model = None
|
|
nam._models_dir = Path("/tmp/pedal-test/nam")
|
|
return nam
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ir():
|
|
ir = MagicMock()
|
|
ir.is_loaded = False
|
|
ir.current_ir = None
|
|
ir._ir_dir = Path("/tmp/pedal-test/irs")
|
|
return ir
|
|
|
|
|
|
@pytest.fixture
|
|
def deps(mock_presets, mock_pipeline, mock_nam, mock_ir):
|
|
return WebServerDeps(
|
|
presets=mock_presets,
|
|
pipeline=mock_pipeline,
|
|
nam_host=mock_nam,
|
|
ir_loader=mock_ir,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(deps):
|
|
"""TestClient with a fresh server per test."""
|
|
server = WebServer(deps)
|
|
# Use TestClient from starlette which FastAPI wraps
|
|
from fastapi.testclient import TestClient
|
|
test_client = TestClient(server._app)
|
|
return test_client
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_deps():
|
|
"""Server with no pedal dependencies connected (disconnected state)."""
|
|
server = WebServer(WebServerDeps())
|
|
test_client = TestClient(server._app)
|
|
return test_client
|
|
|
|
|
|
# ── HTML page tests ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPages:
|
|
"""Verify all HTML pages are served with correct status and content type."""
|
|
|
|
def test_dashboard(self, client):
|
|
res = client.get("/")
|
|
assert res.status_code == 200
|
|
assert "text/html" in res.headers["content-type"]
|
|
|
|
def test_presets_page(self, client):
|
|
res = client.get("/presets")
|
|
assert res.status_code == 200
|
|
assert "text/html" in res.headers["content-type"]
|
|
|
|
def test_models_page(self, client):
|
|
res = client.get("/models")
|
|
assert res.status_code == 200
|
|
assert "text/html" in res.headers["content-type"]
|
|
|
|
def test_irs_page(self, client):
|
|
res = client.get("/irs")
|
|
assert res.status_code == 200
|
|
assert "text/html" in res.headers["content-type"]
|
|
|
|
def test_settings_page(self, client):
|
|
res = client.get("/settings")
|
|
assert res.status_code == 200
|
|
assert "text/html" in res.headers["content-type"]
|
|
|
|
def test_404_page(self, client):
|
|
res = client.get("/nonexistent")
|
|
assert res.status_code == 404
|
|
|
|
def test_static_css(self, client):
|
|
res = client.get("/static/style.css")
|
|
assert res.status_code == 200
|
|
assert "text/css" in res.headers["content-type"]
|
|
|
|
|
|
# ── REST API tests ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAPIState:
|
|
"""GET /api/state — pedal state snapshot."""
|
|
|
|
def test_state_connected(self, client, mock_presets, mock_pipeline, mock_nam, mock_ir):
|
|
res = client.get("/api/state")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["connected"] is True
|
|
assert "current_preset" in data
|
|
assert "bypass" in data
|
|
assert "tuner_enabled" in data
|
|
assert "master_volume" in data
|
|
assert data["master_volume"] == 0.8
|
|
|
|
def test_state_disconnected(self, client_no_deps):
|
|
res = client_no_deps.get("/api/state")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["connected"] is False
|
|
|
|
|
|
class TestAPIPresets:
|
|
"""Preset CRUD endpoints."""
|
|
|
|
def test_list_presets(self, client):
|
|
res = client.get("/api/presets")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "banks" in data
|
|
|
|
def test_list_presets_no_deps(self, client_no_deps):
|
|
res = client_no_deps.get("/api/presets")
|
|
assert res.status_code == 503
|
|
|
|
def test_get_preset_not_found(self, client, mock_presets):
|
|
mock_presets.load.side_effect = FileNotFoundError("No preset")
|
|
res = client.get("/api/presets/99/0")
|
|
assert res.status_code == 404
|
|
|
|
def test_activate_preset(self, client, mock_presets):
|
|
from src.presets.types import Preset
|
|
fake_preset = Preset(name="Test", bank=0, program=0)
|
|
mock_presets.select.return_value = fake_preset
|
|
res = client.post("/api/presets/0/0/activate")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["ok"] is True
|
|
mock_presets.select.assert_called_with(0, 0)
|
|
|
|
def test_save_preset(self, client, mock_presets):
|
|
payload = {
|
|
"name": "My Tone",
|
|
"master_volume": 0.7,
|
|
"chain": [
|
|
{"fx_type": "overdrive", "enabled": True, "bypass": False, "params": {"drive": 0.5}}
|
|
],
|
|
}
|
|
res = client.put("/api/presets/1/2", json=payload)
|
|
assert res.status_code == 200
|
|
assert res.json()["name"] == "My Tone"
|
|
mock_presets.save.assert_called_once()
|
|
|
|
def test_delete_preset(self, client, mock_presets):
|
|
res = client.delete("/api/presets/0/0")
|
|
assert res.status_code == 200
|
|
mock_presets.delete.assert_called_with(0, 0)
|
|
|
|
|
|
class TestAPIBypass:
|
|
"""Bypass toggle endpoint."""
|
|
|
|
def test_bypass_toggle_on(self, client):
|
|
res = client.post("/api/bypass", json={"bypass": True})
|
|
assert res.status_code == 200
|
|
assert res.json()["bypass"] is True
|
|
|
|
def test_bypass_toggle_off(self, client):
|
|
res = client.post("/api/bypass", json={"bypass": False})
|
|
assert res.status_code == 200
|
|
assert res.json()["bypass"] is False
|
|
|
|
def test_bypass_no_deps(self, client_no_deps):
|
|
res = client_no_deps.post("/api/bypass")
|
|
assert res.status_code == 503
|
|
|
|
|
|
class TestAPIVolume:
|
|
"""Volume control endpoint."""
|
|
|
|
def test_set_volume(self, client, mock_pipeline):
|
|
res = client.put("/api/volume", json={"volume": 0.5})
|
|
assert res.status_code == 200
|
|
assert res.json()["volume"] == 0.5
|
|
assert mock_pipeline._master_volume == 0.5
|
|
|
|
def test_set_volume_clamp(self, client, mock_pipeline):
|
|
res = client.put("/api/volume", json={"volume": 2.5})
|
|
assert res.status_code == 200
|
|
assert res.json()["volume"] == 1.0 # clamped
|
|
|
|
|
|
class TestAPIModels:
|
|
"""NAM model listing and loading."""
|
|
|
|
def test_list_models(self, client, mock_nam):
|
|
mock_nam.list_available_models.return_value = []
|
|
res = client.get("/api/models")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "models" in data
|
|
assert "current" in data
|
|
|
|
def test_load_model(self, client, mock_nam):
|
|
mock_nam.load_model.return_value = True
|
|
res = client.post("/api/models/load", json={"path": "/tmp/test.nam"})
|
|
assert res.status_code == 200
|
|
assert res.json()["ok"] is True
|
|
|
|
def test_load_model_fail(self, client, mock_nam):
|
|
mock_nam.load_model.return_value = False
|
|
res = client.post("/api/models/load", json={"path": "/tmp/bad.nam"})
|
|
assert res.status_code == 500
|
|
|
|
def test_unload_model(self, client, mock_nam):
|
|
res = client.post("/api/models/unload")
|
|
assert res.status_code == 200
|
|
mock_nam.unload.assert_called_once()
|
|
|
|
|
|
class TestAPIIRs:
|
|
"""IR listing and loading."""
|
|
|
|
def test_list_irs(self, client, mock_ir):
|
|
mock_ir.get_irs.return_value = []
|
|
res = client.get("/api/irs")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "irs" in data
|
|
|
|
def test_load_ir(self, client, mock_ir):
|
|
mock_ir.load_ir.return_value = True
|
|
res = client.post("/api/irs/load", json={"path": "/tmp/test.wav"})
|
|
assert res.status_code == 200
|
|
assert res.json()["ok"] is True
|
|
|
|
def test_unload_ir(self, client, mock_ir):
|
|
res = client.post("/api/irs/unload")
|
|
assert res.status_code == 200
|
|
mock_ir.unload.assert_called_once()
|
|
|
|
|
|
class TestAPIBlockParams:
|
|
"""FX block parameter schemas."""
|
|
|
|
def test_overdrive_params(self, client):
|
|
res = client.get("/api/block-params/overdrive")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["fx_type"] == "overdrive"
|
|
assert len(data["params"]) > 0
|
|
keys = [p["key"] for p in data["params"]]
|
|
assert "drive" in keys
|
|
|
|
def test_unknown_fx_type(self, client):
|
|
res = client.get("/api/block-params/unknown_fx")
|
|
assert res.status_code == 200
|
|
assert res.json()["params"] == []
|
|
|
|
|
|
class TestAPITuner:
|
|
"""Tuner toggle."""
|
|
|
|
def test_tuner_toggle_on(self, client):
|
|
res = client.post("/api/tuner", json={"enabled": True})
|
|
assert res.status_code == 200
|
|
assert res.json()["tuner_enabled"] is True
|
|
|
|
def test_tuner_toggle_off(self, client):
|
|
res = client.post("/api/tuner", json={"enabled": False})
|
|
assert res.status_code == 200
|
|
assert res.json()["tuner_enabled"] is False
|
|
|
|
|
|
# ── WebSocket tests ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestWebSocket:
|
|
"""WebSocket connection and message handling."""
|
|
|
|
def test_ws_connect_receives_state(self, client, mock_presets):
|
|
"""On connect, server sends 'connected' message with state."""
|
|
from src.presets.types import Preset
|
|
mock_presets.load.return_value = Preset(name="Default", bank=0, program=0)
|
|
|
|
with client.websocket_connect("/ws") as ws:
|
|
data = ws.receive_text()
|
|
msg = json.loads(data)
|
|
assert msg["type"] == "connected"
|
|
assert "state" in msg
|
|
|
|
def test_ws_ping_pong(self, client):
|
|
"""Ping message gets pong response."""
|
|
with client.websocket_connect("/ws") as ws:
|
|
# Consume the initial connected message
|
|
ws.receive_text()
|
|
ws.send_text(json.dumps({"type": "ping"}))
|
|
data = ws.receive_text()
|
|
msg = json.loads(data)
|
|
assert msg["type"] == "pong"
|
|
|
|
def test_ws_get_state(self, client, mock_presets):
|
|
"""Requesting state returns full state object."""
|
|
from src.presets.types import Preset
|
|
mock_presets.load.return_value = Preset(name="Test", bank=1, program=2)
|
|
|
|
with client.websocket_connect("/ws") as ws:
|
|
ws.receive_text() # initial connected
|
|
ws.send_text(json.dumps({"type": "get_state"}))
|
|
data = ws.receive_text()
|
|
msg = json.loads(data)
|
|
assert msg["type"] == "state"
|
|
assert "state" in msg
|
|
|
|
|
|
# ── Error handling ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestErrors:
|
|
"""Edge cases and error responses."""
|
|
|
|
def test_missing_volume_key(self, client):
|
|
"""PUT /api/volume with no 'volume' key defaults."""
|
|
res = client.put("/api/volume", json={})
|
|
assert res.status_code == 200
|
|
assert res.json()["volume"] == 0.8 # default
|
|
|
|
def test_empty_model_load_path(self, client):
|
|
res = client.post("/api/models/load", json={"path": ""})
|
|
assert res.status_code == 400 |