Add Network + Bluetooth config in Web UI
- src/system/network.py: WiFi scan/connect/hotspot via nmcli + iwlist fallback - src/system/bluetooth.py: BT scan/pair/connect/MIDI via bluetoothctl - REST endpoints: /api/wifi/* and /api/bluetooth/* (16 endpoints) - Web UI: tabbed settings with Network + Bluetooth panels - network.js: WiFi scan, connect modal, saved nets, hotspot - bluetooth.js: BT scan, pair/unpair, connect, MIDI service - style.css: tabs, network list, BT device list, signal indicators - scripts/setup-bt-midi.sh: BT MIDI systemd bridge service - tests/test_network.py: 22 tests (nmcli/iwlist, fallback, hotspot) - tests/test_bt.py: 24 tests (status, scan, pair, MIDI, edge cases) - _gather_state includes wifi + bluetooth sub-objects - All 93 tests pass
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Tests for the WiFi network management module (src.system.network).
|
||||
|
||||
Uses mocking to avoid requiring actual WiFi hardware.
|
||||
|
||||
Run with:
|
||||
pytest tests/test_network.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.network import (
|
||||
wifi_scan,
|
||||
wifi_status,
|
||||
wifi_saved_networks,
|
||||
wifi_connect,
|
||||
wifi_disconnect,
|
||||
wifi_forget,
|
||||
hotspot_status,
|
||||
hotspot_enable,
|
||||
hotspot_disable,
|
||||
_has_nmcli,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subprocess():
|
||||
"""Patch subprocess.run globally for all tests in this module."""
|
||||
with patch("src.system.network.subprocess.run") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_hostapd_conf(tmp_path):
|
||||
"""Create a mock /etc/hostapd/hostapd.conf for hotspot tests."""
|
||||
conf = tmp_path / "hostapd.conf"
|
||||
conf.write_text("ssid=Pi-Pedal\nchannel=6\n")
|
||||
return conf
|
||||
|
||||
|
||||
# ── _has_nmcli() tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_has_nmcli_true(mock_subprocess):
|
||||
"""When 'which nmcli' succeeds, _has_nmcli returns True."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
assert _has_nmcli() is True
|
||||
|
||||
|
||||
def test_has_nmcli_false(mock_subprocess):
|
||||
"""When 'which nmcli' fails, _has_nmcli returns False."""
|
||||
mock_subprocess.side_effect = FileNotFoundError("no nmcli")
|
||||
assert _has_nmcli() is False
|
||||
|
||||
|
||||
# ── wifi_scan() tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_scan_nmcli_networks(mock_subprocess):
|
||||
"""wifi_scan returns parsed networks from nmcli."""
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout="Net1:75:WPA2\nNet2:50:WPA2\nNet3:90:Open\n",
|
||||
returncode=0,
|
||||
)
|
||||
networks = wifi_scan()
|
||||
assert len(networks) == 3
|
||||
assert networks[0]["ssid"] == "Net3" # sorted by signal desc
|
||||
assert networks[0]["signal"] == 90
|
||||
assert networks[0]["encryption"] == "Open"
|
||||
|
||||
|
||||
def test_wifi_scan_nmcli_empty(mock_subprocess):
|
||||
"""wifi_scan returns empty list when no networks found."""
|
||||
mock_subprocess.return_value = MagicMock(stdout="", returncode=0)
|
||||
networks = wifi_scan()
|
||||
assert networks == []
|
||||
|
||||
|
||||
def test_wifi_scan_nmcli_fallback_iwlist(mock_subprocess):
|
||||
"""wifi_scan falls back to iwlist when nmcli fails."""
|
||||
# First call (nmcli) fails
|
||||
mock_subprocess.side_effect = [
|
||||
FileNotFoundError("no nmcli"), # _has_nmcli check
|
||||
MagicMock(stdout="", returncode=0), # First nmcli scan attempt
|
||||
]
|
||||
# Actually, let's patch _has_nmcli to make this clearer
|
||||
with patch("src.system.network._has_nmcli", return_value=False):
|
||||
with patch("src.system.network._iwlist_scan", return_value=[
|
||||
{"ssid": "TestNet", "signal": 60, "encryption": "WPA2"}
|
||||
]) as mock_iw:
|
||||
networks = wifi_scan()
|
||||
assert len(networks) == 1
|
||||
assert networks[0]["ssid"] == "TestNet"
|
||||
assert networks[0]["signal"] == 60
|
||||
mock_iw.assert_called_once()
|
||||
|
||||
|
||||
def test_wifi_scan_handles_exception(mock_subprocess):
|
||||
"""wifi_scan returns empty list on unexpected error."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.side_effect = RuntimeError("Unexpected")
|
||||
networks = wifi_scan()
|
||||
assert networks == []
|
||||
|
||||
|
||||
# ── wifi_status() tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_status_connected(mock_subprocess):
|
||||
"""wifi_status returns connected state with SSID and IP."""
|
||||
mock_subprocess.side_effect = [
|
||||
MagicMock(stdout="wlan0:wifi:connected:HomeNet\neth0:ethernet:connected:eth0\n", returncode=0),
|
||||
MagicMock(stdout="GENERAL.CONNECTION:HomeNet\nIP4.ADDRESS:192.168.1.100/24\n", returncode=0),
|
||||
MagicMock(stdout="HomeNet:85\nOtherNet:30\n", returncode=0),
|
||||
]
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
status = wifi_status()
|
||||
assert status["connected"] is True
|
||||
assert status["ssid"] == "HomeNet"
|
||||
assert status["ip"] == "192.168.1.100"
|
||||
assert status["signal"] == 85
|
||||
assert status["device"] == "wlan0"
|
||||
|
||||
|
||||
def test_wifi_status_not_connected(mock_subprocess):
|
||||
"""wifi_status returns disconnected when no WiFi connection."""
|
||||
mock_subprocess.side_effect = [
|
||||
MagicMock(stdout="wlan0:wifi:disconnected:\neth0:ethernet:connected:eth0\n", returncode=0),
|
||||
]
|
||||
status = wifi_status()
|
||||
assert status["connected"] is False
|
||||
assert status["ssid"] is None
|
||||
|
||||
|
||||
def test_wifi_status_no_wifi_device(mock_subprocess):
|
||||
"""wifi_status returns disconnected when no WiFi device exists."""
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout="eth0:ethernet:connected:eth0\n",
|
||||
returncode=0,
|
||||
)
|
||||
status = wifi_status()
|
||||
assert status["connected"] is False
|
||||
|
||||
|
||||
# ── wifi_connect() tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_connect_requires_params():
|
||||
"""wifi_connect returns error when SSID or password missing."""
|
||||
result = wifi_connect("", "password")
|
||||
assert result["ok"] is False
|
||||
assert "SSID" in result["error"]
|
||||
|
||||
result = wifi_connect("MyNet", "")
|
||||
assert result["ok"] is False
|
||||
assert "Password" in result["error"]
|
||||
|
||||
|
||||
def test_wifi_connect_new_network(mock_subprocess):
|
||||
"""wifi_connect calls nmcli to connect to a new network."""
|
||||
# Saved networks (nmcli connection show) returns empty for new
|
||||
# Has nmcli → True from first call check
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.side_effect = [
|
||||
# _nmcli_saved - empty list
|
||||
MagicMock(stdout="", returncode=0),
|
||||
# _nmcli_connect - success
|
||||
MagicMock(stdout="", returncode=0),
|
||||
]
|
||||
result = wifi_connect("NewNet", "secret123")
|
||||
assert result["ok"] is True
|
||||
assert mock_subprocess.call_count >= 2
|
||||
|
||||
|
||||
def test_wifi_connect_existing_network(mock_subprocess):
|
||||
"""wifi_connect activates an already-saved network."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.side_effect = [
|
||||
# _nmcli_saved - existing network
|
||||
MagicMock(stdout="NewNet:abc-123:wireless\nOldNet:def-456:wireless\n", returncode=0),
|
||||
# _nmcli_connect - connection up
|
||||
MagicMock(stdout="", returncode=0),
|
||||
]
|
||||
result = wifi_connect("NewNet", "secret123")
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
# ── wifi_disconnect() tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_disconnect(mock_subprocess):
|
||||
"""wifi_disconnect returns ok."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.side_effect = [
|
||||
# _nmcli_status
|
||||
MagicMock(stdout="wlan0:wifi:connected:HomeNet\n", returncode=0),
|
||||
MagicMock(stdout="GENERAL.CONNECTION:HomeNet\nIP4.ADDRESS:192.168.1.100/24\n", returncode=0),
|
||||
MagicMock(stdout="HomeNet:80\n", returncode=0),
|
||||
# _nmcli_disconnect
|
||||
MagicMock(stdout="", returncode=0),
|
||||
]
|
||||
result = wifi_disconnect()
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
# ── wifi_forget() tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_forget_network(mock_subprocess):
|
||||
"""wifi_forget deletes a saved network."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.side_effect = [
|
||||
# _nmcli_saved
|
||||
MagicMock(stdout="MyNet:abc-123:wireless\nOther:def-456:wireless\n", returncode=0),
|
||||
# _nmcli_forget - nmcli connection delete
|
||||
MagicMock(stdout="", returncode=0),
|
||||
]
|
||||
result = wifi_forget("MyNet")
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_wifi_forget_not_found(mock_subprocess):
|
||||
"""wifi_forget returns ok=False when network not saved."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout="Other:def-456:wireless\n",
|
||||
returncode=0,
|
||||
)
|
||||
result = wifi_forget("NonExistent")
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── wifi_saved_networks() tests ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wifi_saved_networks(mock_subprocess):
|
||||
"""wifi_saved_networks returns saved WiFi connections."""
|
||||
with patch("src.system.network._has_nmcli", return_value=True):
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
stdout="HomeNet:abc-def:wireless\nOfficeNet:ghi-jkl:wireless\neth0:xyz:ethernet\n",
|
||||
returncode=0,
|
||||
)
|
||||
saved = wifi_saved_networks()
|
||||
assert len(saved) == 2
|
||||
assert saved[0]["ssid"] == "HomeNet"
|
||||
assert saved[1]["ssid"] == "OfficeNet"
|
||||
|
||||
|
||||
# ── hotspot_status() tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_hotspot_status_active(mock_subprocess, mock_hostapd_conf):
|
||||
"""hotspot_status returns active state when hostapd is running."""
|
||||
with patch("src.system.network.Path.exists", return_value=True):
|
||||
with patch("src.system.network.Path.read_text", return_value="ssid=Pi-Pedal\nchannel=6\n"):
|
||||
mock_subprocess.side_effect = [
|
||||
MagicMock(stdout="active\n", returncode=0), # systemctl is-active
|
||||
MagicMock(stdout="Station aa:bb:cc:dd:ee:ff\n", returncode=0), # iw station dump
|
||||
]
|
||||
status = hotspot_status()
|
||||
assert status["active"] is True
|
||||
assert status["ssid"] == "Pi-Pedal"
|
||||
assert status["clients"] == 1
|
||||
assert status["ip"] == "192.168.4.1"
|
||||
|
||||
|
||||
def test_hotspot_status_inactive(mock_subprocess):
|
||||
"""hotspot_status returns inactive when hostapd is stopped."""
|
||||
mock_subprocess.return_value = MagicMock(stdout="inactive\n", returncode=0)
|
||||
status = hotspot_status()
|
||||
assert status["active"] is False
|
||||
assert status["clients"] == 0
|
||||
|
||||
|
||||
# ── hotspot_enable() / hotspot_disable() tests ─────────────────────────────────
|
||||
|
||||
|
||||
def test_hotspot_enable_short_password():
|
||||
"""hotspot_enable rejects passwords shorter than 8 chars."""
|
||||
result = hotspot_enable("TestAP", "123")
|
||||
assert result["ok"] is False
|
||||
assert "at least 8 characters" in result["error"].lower()
|
||||
|
||||
|
||||
def test_hotspot_enable_success(mock_subprocess):
|
||||
"""hotspot_enable calls the setup script."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
result = hotspot_enable("TestAP", "longpassword")
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_hotspot_enable_failure(mock_subprocess):
|
||||
"""hotspot_enable returns error on script failure."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed")
|
||||
result = hotspot_enable("TestAP", "longpassword")
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_hotspot_disable_success(mock_subprocess):
|
||||
"""hotspot_disable calls the disable script."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
result = hotspot_disable()
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_hotspot_disable_failure(mock_subprocess):
|
||||
"""hotspot_disable returns error on script failure."""
|
||||
mock_subprocess.return_value = MagicMock(returncode=1, stderr="Script failed")
|
||||
result = hotspot_disable()
|
||||
assert result["ok"] is False
|
||||
Reference in New Issue
Block a user