07e98aab57
The test_state_connected test timed out because _gather_bt_state calls real bluetoothctl which hangs for 30s when no BT adapter is present. Since these gathers depend on hardware (nmcli, bluetoothctl) that only exists on the Pi, mock them in the test fixture so the test suite runs cleanly on any machine. 43/43 tests pass in 2.93s.
450 lines
15 KiB
Python
450 lines
15 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
|
|
pl.routing_mode = "mono"
|
|
pl.routing_breakpoint = 7
|
|
|
|
def _set_routing(mode, breakpoint=7):
|
|
pl.routing_mode = mode
|
|
pl.routing_breakpoint = breakpoint
|
|
pl.set_routing = MagicMock(side_effect=_set_routing)
|
|
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, monkeypatch):
|
|
"""TestClient with a fresh server per test."""
|
|
# Mock hardware-dependent gathers so tests don't hang on bluetoothctl/nmcli
|
|
monkeypatch.setattr(WebServer, "_gather_wifi_state", lambda self: {
|
|
"connected": False, "ssid": None, "ip": None, "signal": 0,
|
|
"hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0,
|
|
})
|
|
monkeypatch.setattr(WebServer, "_gather_bt_state", lambda self: {
|
|
"available": False, "powered": False, "name": None,
|
|
"address": None, "discoverable": False,
|
|
"midi_enabled": False, "midi_running": False,
|
|
})
|
|
server = WebServer(deps)
|
|
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
|
|
|
|
|
|
class TestAPIRouting:
|
|
"""4CM routing mode and breakpoint endpoints."""
|
|
|
|
def test_get_routing_default(self, client, mock_pipeline):
|
|
"""GET /api/routing returns current routing config."""
|
|
res = client.get("/api/routing")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["routing_mode"] == "mono"
|
|
assert data["routing_breakpoint"] == 7
|
|
|
|
def test_get_routing_no_deps(self, client_no_deps):
|
|
"""GET /api/routing returns 503 when pipeline not connected."""
|
|
res = client_no_deps.get("/api/routing")
|
|
assert res.status_code == 503
|
|
|
|
def test_set_routing_mode_4cm(self, client, mock_pipeline):
|
|
"""POST /api/routing switches to 4CM mode."""
|
|
res = client.post("/api/routing", json={"routing_mode": "4cm"})
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["routing_mode"] == "4cm"
|
|
mock_pipeline.set_routing.assert_called_with("4cm", 7)
|
|
|
|
def test_set_routing_mode_mono(self, client, mock_pipeline):
|
|
"""POST /api/routing switches back to mono."""
|
|
res = client.post("/api/routing", json={"routing_mode": "mono"})
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["routing_mode"] == "mono"
|
|
mock_pipeline.set_routing.assert_called_with("mono", 7)
|
|
|
|
def test_set_routing_breakpoint(self, client, mock_pipeline):
|
|
"""POST /api/routing sets breakpoint position."""
|
|
res = client.post("/api/routing", json={"routing_breakpoint": 3})
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["routing_breakpoint"] == 3
|
|
mock_pipeline.set_routing.assert_called_with("mono", 3)
|
|
|
|
def test_set_routing_both(self, client, mock_pipeline):
|
|
"""POST /api/routing sets both mode and breakpoint."""
|
|
res = client.post("/api/routing", json={
|
|
"routing_mode": "4cm",
|
|
"routing_breakpoint": 5,
|
|
})
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["routing_mode"] == "4cm"
|
|
assert data["routing_breakpoint"] == 5
|
|
mock_pipeline.set_routing.assert_called_with("4cm", 5)
|
|
|
|
def test_set_routing_no_deps(self, client_no_deps):
|
|
"""POST /api/routing returns 503 when pipeline not connected."""
|
|
res = client_no_deps.post("/api/routing", json={"routing_mode": "4cm"})
|
|
assert res.status_code == 503 |