"""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._current_snapshot = 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._tuner_frequency = 0.0 pl._tuner_note = '--' pl._tuner_cents = 0 pl._tuner_string = -1 pl._tuner_confidence = 0.0 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_password": 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