wrolpi/controller/test/test_status.py
Roland 1a88fb4a5b
controller: report idle (0 RPM) fans; prefer the CPU fan over case fans
The RPi 5 firmware stops the fan entirely when cool, so a 0 RPM
reading is normal and must not be hidden. On multi-fan machines,
prefer a CPU-labeled hwmon group over faster case fans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:52:58 -06:00

301 lines
10 KiB
Python

"""
Unit tests for controller.lib.status module.
The status module now returns formats compatible with the main WROLPi API
so the React app can seamlessly use either source.
"""
from unittest import mock
from controller.lib.status import (
get_cpu_status,
get_disk_bandwidth_status,
get_drive_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_primary_drive_status,
reset_bandwidth_stats,
)
class TestGetCpuStatus:
"""Tests for get_cpu_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_cpu_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_cpu_status()
assert "percent" in result
assert "cores" in result
assert "cur_frequency" in result
assert "max_frequency" in result
assert "min_frequency" in result
assert "temperature" in result
assert "high_temperature" in result
assert "critical_temperature" in result
def test_percent_is_numeric(self):
"""CPU percent should be a number."""
result = get_cpu_status()
assert isinstance(result["percent"], (int, float))
assert 0 <= result["percent"] <= 100
def test_cores_is_positive(self):
"""Core count should be a positive integer."""
result = get_cpu_status()
assert isinstance(result["cores"], int)
assert result["cores"] > 0
def test_fan_rpm(self):
"""Fan RPM should be reported when a fan sensor exists (e.g. RPi 5 pwmfan)."""
fan = mock.Mock()
fan.current = 2599
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value={"pwmfan": [fan]}):
result = get_cpu_status()
assert result["fan_rpm"] == 2599
def test_fan_rpm_zero_is_reported(self):
"""A stopped/idle fan (0 RPM) should be reported, not hidden (RPi 5 stops its fan when cool)."""
fan = mock.Mock()
fan.current = 0
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value={"pwmfan": [fan]}):
result = get_cpu_status()
assert result["fan_rpm"] == 0
def test_fan_rpm_prefers_cpu_fan(self):
"""When multiple fan groups exist, the CPU fan should be preferred over faster case fans."""
cpu_fan = mock.Mock()
cpu_fan.current = 1200
case_fan = mock.Mock()
case_fan.current = 3000
fans = {"dell_smm_cpu": [cpu_fan], "case": [case_fan]}
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value=fans):
result = get_cpu_status()
assert result["fan_rpm"] == 1200
def test_fan_rpm_no_fan(self):
"""Fan RPM should be None when no fan sensor exists."""
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value={}):
result = get_cpu_status()
assert result["fan_rpm"] is None
def test_fan_rpm_sensor_error(self):
"""Fan RPM should be None when the sensor read fails."""
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, side_effect=OSError("no sysfs")):
result = get_cpu_status()
assert result["fan_rpm"] is None
class TestGetMemoryStatus:
"""Tests for get_memory_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_memory_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_memory_status()
assert "total" in result
assert "used" in result
assert "free" in result
assert "cached" in result
def test_total_is_positive(self):
"""Memory total should be positive."""
result = get_memory_status()
assert result["total"] > 0
def test_bytes_are_non_negative(self):
"""Memory bytes should be non-negative."""
result = get_memory_status()
assert result["used"] >= 0
assert result["free"] >= 0
assert result["cached"] >= 0
class TestGetLoadStatus:
"""Tests for get_load_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_load_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_load_status()
assert "minute_1" in result
assert "minute_5" in result
assert "minute_15" in result
def test_values_are_strings(self):
"""Load values should be string representations of numbers (main API format)."""
result = get_load_status()
# Values are strings in the main API format
assert isinstance(result["minute_1"], str)
assert isinstance(result["minute_5"], str)
assert isinstance(result["minute_15"], str)
# But they should be parseable as floats
assert float(result["minute_1"]) >= 0
assert float(result["minute_5"]) >= 0
assert float(result["minute_15"]) >= 0
class TestGetDriveStatus:
"""Tests for get_drive_status function."""
def test_returns_list(self):
"""Should return a list."""
result = get_drive_status()
assert isinstance(result, list)
def test_returns_drives_or_empty(self):
"""Should return drives list (may be empty in Docker containers)."""
result = get_drive_status()
# The result should be a valid list
# May be empty in Docker containers where root fs is not visible
assert isinstance(result, list)
def test_drive_has_required_keys(self):
"""Each drive should have all required keys (main API format)."""
result = get_drive_status()
if result: # Only test if we have drives
drive = result[0]
assert "mount" in drive
assert "percent" in drive
assert "size" in drive
assert "used" in drive
class TestGetPrimaryDriveStatus:
"""Tests for get_primary_drive_status function."""
def test_returns_none_when_not_mounted(self):
"""Should return None when /media/wrolpi is not mounted."""
# In most test environments, /media/wrolpi won't be mounted
result = get_primary_drive_status()
# This could be None or a dict depending on the environment
assert result is None or isinstance(result, dict)
def test_returns_dict_when_mounted(self):
"""Should return drive info when /media/wrolpi is mounted."""
# Mock the get_drive_status to return a drive at /media/wrolpi
mock_drives = [
{
"mount": "/media/wrolpi",
"percent": 50,
"size": 1000000000,
"used": 500000000,
}
]
with mock.patch(
"controller.lib.status.get_drive_status", return_value=mock_drives
):
result = get_primary_drive_status()
assert result is not None
assert result["mount"] == "/media/wrolpi"
class TestGetNetworkStatus:
"""Tests for get_network_status function."""
def test_returns_dict(self):
"""Should return a dict (main API format: dict of interface name -> stats)."""
result = get_network_status()
assert isinstance(result, dict)
def test_excludes_loopback(self):
"""Should not include the loopback interface."""
result = get_network_status()
assert "lo" not in result
def test_interface_has_required_keys(self):
"""Each interface should have required keys (main API format)."""
result = get_network_status()
if result: # Only test if we have interfaces
iface = list(result.values())[0]
assert "name" in iface
assert "bytes_sent" in iface
assert "bytes_recv" in iface
assert "speed" in iface
assert "now" in iface
class TestGetPowerStatus:
"""Tests for get_power_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_power_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_power_status()
assert "under_voltage" in result
assert "over_current" in result
def test_values_are_bool(self):
"""All values should be boolean."""
result = get_power_status()
assert isinstance(result["under_voltage"], bool)
assert isinstance(result["over_current"], bool)
class TestNetworkBandwidthRates:
"""Tests for network bandwidth per-second rate calculations."""
def test_first_call_returns_zero_rates(self):
"""First call should return zero rates (no previous data)."""
reset_bandwidth_stats()
result = get_network_status()
if result:
iface = list(result.values())[0]
assert iface['bytes_recv_ps'] == 0
assert iface['bytes_sent_ps'] == 0
assert iface['elapsed'] == 0
def test_second_call_has_elapsed_time(self):
"""Second call should have non-zero elapsed time."""
import time
reset_bandwidth_stats()
get_network_status()
time.sleep(1.1) # Wait > 1 second since elapsed is int (truncated)
result = get_network_status()
if result:
iface = list(result.values())[0]
assert iface['elapsed'] >= 1
class TestDiskBandwidthRates:
"""Tests for disk bandwidth per-second rate calculations."""
def test_first_call_returns_zero_rates(self):
"""First call should return zero rates."""
reset_bandwidth_stats()
result = get_disk_bandwidth_status()
if result:
disk = list(result.values())[0]
assert disk['bytes_read_ps'] == 0
assert disk['bytes_write_ps'] == 0
assert disk['elapsed'] == 0
def test_second_call_has_elapsed_time(self):
"""Second call should have non-zero elapsed time."""
import time
reset_bandwidth_stats()
get_disk_bandwidth_status()
time.sleep(1.1) # Wait > 1 second since elapsed is int (truncated)
result = get_disk_bandwidth_status()
if result:
disk = list(result.values())[0]
assert disk['elapsed'] >= 1
assert 'speed' in disk