Files
pi-multifx-pedal/tests/test_web.py
T
shawn 07e98aab57 Fix test_web: mock _gather_wifi_state/_gather_bt_state to avoid bluetoothctl 30s hang on non-Pi dev machines
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.
2026-06-09 08:08:11 -04:00

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