"""Tests for the Bluetooth management module (src.system.bluetooth). Uses mocking to avoid requiring actual Bluetooth hardware. Run with: pytest tests/test_bt.py -v """ from __future__ import annotations import subprocess import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest # 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.system.bluetooth import ( bt_status, bt_power, bt_discoverable, bt_scan, bt_paired_devices, bt_pair, bt_unpair, bt_connect, bt_disconnect, bt_midi_status, bt_midi_enable, bt_midi_disable, bt_midi_connected_devices, ) # ── Fixtures ───────────────────────────────────────────────────────────────── @pytest.fixture def mock_subprocess(): """Patch subprocess.run globally for all BT tests.""" with patch("src.system.bluetooth.subprocess.run") as mock: yield mock # ── bt_status() tests ───────────────────────────────────────────────────────── def test_bt_status_powered_on(mock_subprocess): """bt_status returns powered-on state.""" mock_subprocess.return_value = MagicMock( stdout="Controller AA:BB:CC:DD:EE:FF\n" "\tName: pi-pedal\n" "\tPowered: yes\n" "\tDiscoverable: yes\n" "\tPairable: yes\n", returncode=0, ) status = bt_status() assert status["available"] is True assert status["powered"] is True assert status["name"] == "pi-pedal" assert status["discoverable"] is True assert status["pairable"] is True assert status["address"] == "AA:BB:CC:DD:EE:FF" def test_bt_status_powered_off(mock_subprocess): """bt_status returns powered-off state.""" mock_subprocess.return_value = MagicMock( stdout="Controller AA:BB:CC:DD:EE:FF\n" "\tName: pi-pedal\n" "\tPowered: no\n" "\tDiscoverable: no\n" "\tPairable: no\n", returncode=0, ) status = bt_status() assert status["powered"] is False assert status["discoverable"] is False def test_bt_status_not_available(mock_subprocess): """bt_status returns not available when bluetoothctl fails.""" mock_subprocess.side_effect = FileNotFoundError("no bluetoothctl") status = bt_status() assert status["available"] is False assert status["powered"] is False # ── bt_power() tests ────────────────────────────────────────────────────────── def test_bt_power_on(mock_subprocess): """bt_power(on=True) powers Bluetooth on.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # bluetoothctl power on MagicMock( # bt_status call stdout="Controller A\n\tPowered: yes\n", returncode=0, ), ] result = bt_power(on=True) assert result["ok"] is True def test_bt_power_off(mock_subprocess): """bt_power(on=False) powers Bluetooth off.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # bluetoothctl power off MagicMock( # bt_status call stdout="Controller A\n\tPowered: no\n", returncode=0, ), ] result = bt_power(on=False) assert result["ok"] is True # ── bt_discoverable() tests ──────────────────────────────────────────────────── def test_bt_discoverable_on(mock_subprocess): """bt_discoverable(on=True) makes adapter discoverable.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # discoverable on MagicMock( # bt_status stdout="Controller A\n\tDiscoverable: yes\n", returncode=0, ), ] result = bt_discoverable(on=True) assert result["ok"] is True def test_bt_discoverable_off(mock_subprocess): """bt_discoverable(on=False) hides adapter.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), MagicMock( stdout="Controller A\n\tDiscoverable: no\n", returncode=0, ), ] result = bt_discoverable(on=False) assert result["ok"] is True # ── bt_scan() tests ─────────────────────────────────────────────────────────── def test_bt_scan_finds_devices(mock_subprocess): """bt_scan returns discovered devices.""" # Simulate the three bluetoothctl calls mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # scan on MagicMock(stdout="", returncode=0), # scan off MagicMock( # devices stdout="Device AA:BB:CC:DD:EE:FF My Guitar\n" "Device 11:22:33:44:55:66 Expression Pedal\n", returncode=0, ), ] # bt_scan uses time.sleep which we don't want to actually wait with patch("src.system.bluetooth.time.sleep", return_value=None): result = bt_scan(timeout=1) assert result["ok"] is True assert len(result["devices"]) == 2 assert result["devices"][0]["name"] == "My Guitar" assert result["devices"][0]["address"] == "AA:BB:CC:DD:EE:FF" assert result["devices"][1]["name"] == "Expression Pedal" def test_bt_scan_no_devices(mock_subprocess): """bt_scan returns empty list when no devices found.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # scan on MagicMock(stdout="", returncode=0), # scan off MagicMock(stdout="", returncode=0), # devices - empty ] with patch("src.system.bluetooth.time.sleep", return_value=None): result = bt_scan(timeout=1) assert result["ok"] is True assert result["devices"] == [] # ── bt_paired_devices() tests ────────────────────────────────────────────────── def test_bt_paired_devices(mock_subprocess): """bt_paired_devices returns paired device list.""" mock_subprocess.side_effect = [ MagicMock( # paired-devices stdout="Device AA:BB:CC:DD:EE:FF MIDI Controller\n" "Device 11:22:33:44:55:66 Footswitch\n", returncode=0, ), MagicMock( # info stdout="", returncode=0, ), ] devices = bt_paired_devices() assert len(devices) == 2 assert devices[0]["name"] == "MIDI Controller" assert devices[0]["address"] == "AA:BB:CC:DD:EE:FF" def test_bt_paired_devices_empty(mock_subprocess): """bt_paired_devices returns empty list.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # paired-devices - empty MagicMock(stdout="", returncode=0), ] devices = bt_paired_devices() assert devices == [] # ── bt_pair() tests ─────────────────────────────────────────────────────────── def test_bt_pair_success(mock_subprocess): """bt_pair successfully pairs with a device.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # trust MagicMock(stdout="Pairing successful\n", returncode=0), # pair MagicMock(stdout="", returncode=0), # connect ] with patch("src.system.bluetooth.time.sleep", return_value=None): result = bt_pair("AA:BB:CC:DD:EE:FF") assert result["ok"] is True def test_bt_pair_invalid_address(): """bt_pair returns error for invalid MAC address.""" result = bt_pair("invalid") assert result["ok"] is False assert "Invalid" in result["error"] def test_bt_pair_failure(mock_subprocess): """bt_pair returns error when pairing fails.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # trust MagicMock(stdout="Failed to pair\n", returncode=0), # pair ] with patch("src.system.bluetooth.time.sleep", return_value=None): result = bt_pair("AA:BB:CC:DD:EE:FF") assert result["ok"] is False assert "failed" in result["error"].lower() # ── bt_unpair() tests ────────────────────────────────────────────────────────── def test_bt_unpair_success(mock_subprocess): """bt_unpair removes a device.""" mock_subprocess.side_effect = [ MagicMock(stdout="", returncode=0), # disconnect MagicMock(stdout="Device has been removed\n", returncode=0), # remove ] with patch("src.system.bluetooth.time.sleep", return_value=None): result = bt_unpair("AA:BB:CC:DD:EE:FF") assert result["ok"] is True def test_bt_unpair_invalid(): """bt_unpair returns error for invalid address.""" result = bt_unpair("invalid") assert result["ok"] is False # ── bt_connect() / bt_disconnect() tests ─────────────────────────────────────── def test_bt_connect_success(mock_subprocess): """bt_connect connects to a paired device.""" mock_subprocess.return_value = MagicMock( stdout="Connection successful\n", returncode=0, ) result = bt_connect("AA:BB:CC:DD:EE:FF") assert result["ok"] is True def test_bt_connect_invalid(): """bt_connect returns error for invalid address.""" result = bt_connect("bad") assert result["ok"] is False def test_bt_disconnect_success(mock_subprocess): """bt_disconnect disconnects a device.""" mock_subprocess.return_value = MagicMock(stdout="", returncode=0) result = bt_disconnect("AA:BB:CC:DD:EE:FF") assert result["ok"] is True def test_bt_disconnect_invalid(): """bt_disconnect returns error for invalid address.""" result = bt_disconnect("bad") assert result["ok"] is False # ── bt_midi_status() tests ───────────────────────────────────────────────────── def test_bt_midi_status_enabled_running(mock_subprocess): """bt_midi_status shows enabled and running.""" mock_subprocess.side_effect = [ MagicMock(stdout="enabled\n", returncode=0), # is-enabled MagicMock(stdout="active\n", returncode=0), # is-active ] status = bt_midi_status() assert status["enabled"] is True assert status["running"] is True def test_bt_midi_status_disabled(mock_subprocess): """bt_midi_status shows disabled.""" mock_subprocess.side_effect = [ MagicMock(stdout="disabled\n", returncode=0), MagicMock(stdout="inactive\n", returncode=0), ] status = bt_midi_status() assert status["enabled"] is False assert status["running"] is False # ── bt_midi_enable() / bt_midi_disable() tests ───────────────────────────────── def test_bt_midi_enable_success(mock_subprocess): """bt_midi_enable starts the MIDI service.""" mock_subprocess.return_value = MagicMock(returncode=0) result = bt_midi_enable() assert result["ok"] is True def test_bt_midi_enable_failure(mock_subprocess): """bt_midi_enable returns error on failure.""" mock_subprocess.side_effect = subprocess.CalledProcessError(1, "systemctl") result = bt_midi_enable() assert result["ok"] is False def test_bt_midi_disable_success(mock_subprocess): """bt_midi_disable stops the MIDI service.""" mock_subprocess.return_value = MagicMock(returncode=0) result = bt_midi_disable() assert result["ok"] is True # ── bt_midi_connected_devices() tests ────────────────────────────────────────── def test_bt_midi_connected_devices(mock_subprocess): """bt_midi_connected_devices returns connected devices.""" mock_subprocess.side_effect = [ MagicMock( # paired-devices stdout="Device AA:BB:CC:DD:EE:FF MIDI Keys\n" "Device 11:22:33:44:55:66 Pedal\n", returncode=0, ), MagicMock(stdout="", returncode=0), # info - none connected ] devices = bt_midi_connected_devices() assert devices == [] # none connected def test_bt_midi_connected_devices_with_connected(mock_subprocess): """bt_midi_connected_devices returns only connected devices.""" with patch("src.system.bluetooth.bt_paired_devices", return_value=[ {"address": "AA:BB:CC:DD:EE:FF", "name": "MIDI Keys", "connected": True}, {"address": "11:22:33:44:55:66", "name": "Pedal", "connected": False}, ]): devices = bt_midi_connected_devices() assert len(devices) == 1 assert devices[0]["name"] == "MIDI Keys"