From 5b3e1306f82b2d370154e57657250b3107ae768a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 15 Jan 2024 18:26:49 +0100 Subject: [PATCH] Add tests for System Monitor (#107891) * Add tests * no coordinator * Coverage * processes * test init * util * test icon * Add tests * Mod tests * Add tests * Test attributes * snapshots * icon * test disk mounts * fixes * svmem * cache_clear * test icon * reset icon test * test_processor_temperature * fix tests on macos --------- Co-authored-by: J. Nick Koston --- .coveragerc | 3 - .../components/systemmonitor/sensor.py | 32 +- tests/components/systemmonitor/conftest.py | 233 +++++++++- .../systemmonitor/snapshots/test_sensor.ambr | 399 ++++++++++++++++++ tests/components/systemmonitor/test_init.py | 60 +++ tests/components/systemmonitor/test_sensor.py | 346 +++++++++++++++ tests/components/systemmonitor/test_util.py | 90 ++++ 7 files changed, 1147 insertions(+), 16 deletions(-) create mode 100644 tests/components/systemmonitor/snapshots/test_sensor.ambr create mode 100644 tests/components/systemmonitor/test_init.py create mode 100644 tests/components/systemmonitor/test_sensor.py create mode 100644 tests/components/systemmonitor/test_util.py diff --git a/.coveragerc b/.coveragerc index 88a9f96a608..f6ecdc3e718 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1316,9 +1316,6 @@ omit = homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/system_bridge/update.py - homeassistant/components/systemmonitor/__init__.py - homeassistant/components/systemmonitor/sensor.py - homeassistant/components/systemmonitor/util.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py homeassistant/components/tado/climate.py diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 95437c7fa4c..1a48e34d0e9 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import datetime, timedelta -from functools import cache +from functools import cache, lru_cache import logging import os import socket import sys -from typing import Any +from typing import Any, Literal import psutil import voluptuous as vol @@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" -if sys.maxsize > 2**32: - CPU_ICON = "mdi:cpu-64-bit" -else: - CPU_ICON = "mdi:cpu-32-bit" SENSOR_TYPE_NAME = 0 SENSOR_TYPE_UOM = 1 @@ -70,6 +66,14 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" +@lru_cache +def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: + """Return cpu icon.""" + if sys.maxsize > 2**32: + return "mdi:cpu-64-bit" + return "mdi:cpu-32-bit" + + @dataclass(frozen=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" @@ -121,19 +125,19 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { "load_15m": SysMonitorSensorEntityDescription( key="load_15m", name="Load (15m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_1m": SysMonitorSensorEntityDescription( key="load_1m", name="Load (1m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_5m": SysMonitorSensorEntityDescription( key="load_5m", name="Load (5m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "memory_free": SysMonitorSensorEntityDescription( @@ -210,14 +214,14 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { "process": SysMonitorSensorEntityDescription( key="process", name="Process", - icon=CPU_ICON, + icon=get_cpu_icon(), mandatory_arg=True, ), "processor_use": SysMonitorSensorEntityDescription( key="processor_use", name="Processor use", native_unit_of_measurement=PERCENTAGE, - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "processor_temperature": SysMonitorSensorEntityDescription( @@ -751,7 +755,11 @@ def _getloadavg() -> tuple[float, float, float]: def _read_cpu_temperature() -> float | None: """Attempt to read CPU / processor temperature.""" - temps = psutil.sensors_temperatures() + try: + temps = psutil.sensors_temperatures() + except AttributeError: + # Linux, macOS + return None for name, entries in temps.items(): for i, entry in enumerate(entries, start=1): diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index ca21c971cf1..b349e5cf5e1 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -1,11 +1,61 @@ """Fixtures for the System Monitor integration.""" from __future__ import annotations +from collections import namedtuple from collections.abc import Generator -from unittest.mock import AsyncMock, patch +import socket +from unittest.mock import AsyncMock, Mock, patch +from psutil import NoSuchProcess, Process +from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr, sswap import pytest +from homeassistant.components.systemmonitor.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +# Different depending on platform so making according to Linux +svmem = namedtuple( + "svmem", + [ + "total", + "available", + "percent", + "used", + "free", + "active", + "inactive", + "buffers", + "cached", + "shared", + "slab", + ], +) + + +@pytest.fixture(autouse=True) +def mock_sys_platform() -> Generator[None, None, None]: + """Mock sys platform to Linux.""" + with patch("sys.platform", "linux"): + yield + + +class MockProcess(Process): + """Mock a Process class.""" + + def __init__(self, name: str, ex: bool = False) -> None: + """Initialize the process.""" + super().__init__(1) + self._name = name + self._ex = ex + + def name(self): + """Return a name.""" + if self._ex: + raise NoSuchProcess(1, self._name) + return self._name + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -15,3 +65,184 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "sensor": {"process": ["python3", "pip"]}, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + }, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + +@pytest.fixture +def mock_process() -> list[MockProcess]: + """Mock process.""" + _process_python = MockProcess("python3") + _process_pip = MockProcess("pip") + return [_process_python, _process_pip] + + +@pytest.fixture +def mock_psutil(mock_process: list[MockProcess]) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.sensor.psutil", + autospec=True, + ) as mock_psutil: + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**2, 300 * 1024**2, 200 * 1024**2, 60.0 + ) + mock_psutil.swap_memory.return_value = sswap( + 100 * 1024**2, 60 * 1024**2, 40 * 1024**2, 60.0, 1, 1 + ) + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 40 * 1024**2, + 40.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + "eth1": snetio(200 * 1024**2, 200 * 1024**2, 150, 150, 0, 0, 0, 0), + "vethxyzxyz": snetio(300 * 1024**2, 300 * 1024**2, 150, 150, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_psutil.cpu_percent.return_value = 10.0 + mock_psutil.boot_time.return_value = 1703973338.0 + mock_psutil.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_psutil.sensors_temperatures = Mock() + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_psutil.NoSuchProcess = NoSuchProcess + yield mock_psutil + + +@pytest.fixture +def mock_util(mock_process) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.util.psutil", autospec=True + ) as mock_util: + mock_util.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_process = [MockProcess("python3")] + mock_util.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_util.sensors_temperatures = Mock() + mock_util.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), + sdiskpart("test3", "/incorrect", "", "", 1, 1), + sdiskpart("proc", "/proc/run", "proc", "", 1, 1), + ] + mock_util.disk_usage.return_value = sdiskusage(10, 10, 0, 0) + yield mock_util + + +@pytest.fixture +def mock_os() -> Mock: + """Mock os.""" + with patch("homeassistant.components.systemmonitor.sensor.os") as mock_os, patch( + "homeassistant.components.systemmonitor.util.os" + ) as mock_os_util: + mock_os_util.name = "nt" + mock_os.getloadavg.return_value = (1, 2, 3) + yield mock_os diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..be32e1f54ef --- /dev/null +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -0,0 +1,399 @@ +# serializer version: 1 +# name: test_sensor[System Monitor Disk free / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free / - state] + '0.2' +# --- +# name: test_sensor[System Monitor Disk free /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free /media/share - state] + '0.2' +# --- +# name: test_sensor[System Monitor Disk use (percent) / - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) / - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /home/notexist/', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use (percent) /media/share - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) /media/share - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use / - state] + '0.3' +# --- +# name: test_sensor[System Monitor Disk use /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use /media/share - state] + '0.3' +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - state] + '192.168.1.1' +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - state] + '192.168.10.1' +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Last boot - attributes] + ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'System Monitor Last boot', + }) +# --- +# name: test_sensor[System Monitor Last boot - state] + '2023-12-30T21:55:38+00:00' +# --- +# name: test_sensor[System Monitor Load (15m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (15m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (15m) - state] + '3' +# --- +# name: test_sensor[System Monitor Load (1m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (1m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (1m) - state] + '1' +# --- +# name: test_sensor[System Monitor Load (5m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (5m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (5m) - state] + '2' +# --- +# name: test_sensor[System Monitor Memory free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory use (percent) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Memory use (percent)', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Memory use (percent) - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory use', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory use - state] + '60.0' +# --- +# name: test_sensor[System Monitor Network in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Packets in eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets in eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Packets out eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets out eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Process pip - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process pip', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process pip - state] + 'on' +# --- +# name: test_sensor[System Monitor Process python3 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process python3', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process python3 - state] + 'on' +# --- +# name: test_sensor[System Monitor Processor temperature - attributes] + ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'System Monitor Processor temperature', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Processor temperature - state] + '50.0' +# --- +# name: test_sensor[System Monitor Processor use - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Processor use', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Processor use - state] + '10' +# --- +# name: test_sensor[System Monitor Swap free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Swap use (percent) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Swap use (percent)', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Swap use (percent) - state] + '60.0' +# --- +# name: test_sensor[System Monitor Swap use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap use', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap use - state] + '60.0' +# --- diff --git a/tests/components/systemmonitor/test_init.py b/tests/components/systemmonitor/test_init.py new file mode 100644 index 00000000000..a352f9a1b95 --- /dev/null +++ b/tests/components/systemmonitor/test_init.py @@ -0,0 +1,60 @@ +"""Test for System Monitor init.""" +from __future__ import annotations + +from homeassistant.components.systemmonitor.const import CONF_PROCESS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_load_unload_entry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test load and unload an entry.""" + + assert mock_added_config_entry.state == ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_added_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_adding_processor_to_options( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test options listener.""" + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is None + + result = await hass.config_entries.options.async_init( + mock_added_config_entry.entry_id + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["python3", "pip", "systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["python3", "pip", "systemd"], + }, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + } + + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is not None + assert process_sensor.state == STATE_OFF diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py new file mode 100644 index 00000000000..d173bb11d2e --- /dev/null +++ b/tests/components/systemmonitor/test_sensor.py @@ -0,0 +1,346 @@ +"""Test System Monitor sensor.""" +from datetime import timedelta +import socket +from unittest.mock import Mock, patch + +from freezegun.api import FrozenDateTimeFactory +from psutil._common import shwtemp, snetio, snicaddr +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.systemmonitor.sensor import ( + _read_cpu_temperature, + get_cpu_icon, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import MockProcess, svmem + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + assert memory_sensor.attributes == { + "state_class": "measurement", + "unit_of_measurement": "MiB", + "device_class": "data_size", + "icon": "mdi:memory", + "friendly_name": "System Monitor Memory free", + } + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + for entity in er.async_entries_for_config_entry( + entity_registry, mock_added_config_entry.entry_id + ): + state = hass.states.get(entity.entity_id) + assert state.state == snapshot(name=f"{state.name} - state") + assert state.attributes == snapshot(name=f"{state.name} - attributes") + + +async def test_sensor_not_loading_veth_networks( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, +) -> None: + """Test the sensor.""" + network_sensor_1 = hass.states.get("sensor.system_monitor_network_out_eth1") + network_sensor_2 = hass.states.get( + "sensor.sensor.system_monitor_network_out_vethxyzxyz" + ) + assert network_sensor_1 is not None + assert network_sensor_1.state == "200.0" + assert network_sensor_2 is None + + +async def test_sensor_icon( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor icon for 32bit/64bit system.""" + + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**32): + assert get_cpu_icon() == "mdi:cpu-32-bit" + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**64): + assert get_cpu_icon() == "mdi:cpu-64-bit" + + +async def test_sensor_yaml( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "disk_use_percent"}, + {"type": "disk_use_percent", "arg": "/media/share"}, + {"type": "memory_free", "arg": "/"}, + {"type": "network_out", "arg": "eth0"}, + {"type": "process", "arg": "python3"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + +async def test_sensor_yaml_fails_missing_argument( + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML fails on missing mandatory argument.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "network_in"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + assert "Mandatory 'arg' is missing for sensor type 'network_in'" in caplog.text + + +async def test_sensor_updating( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + mock_psutil.virtual_memory.side_effect = Exception("Failed to update") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == STATE_UNAVAILABLE + + mock_psutil.virtual_memory.side_effect = None + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 25 * 1024**2, + 25.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "25.0" + + +async def test_sensor_process_fails( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test process not exist failure.""" + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + _process = MockProcess("python3", True) + + mock_psutil.process_iter.return_value = [_process] + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + # assert process_sensor.state == STATE_ON + + assert "Failed to load process with ID: 1, old name: python3" in caplog.text + + +async def test_sensor_network_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test process not exist failure.""" + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "200.0" + assert packets_out_sensor.state == "150" + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(200 * 1024**2, 200 * 1024**2, 100, 100, 0, 0, 0, 0), + "eth1": snetio(400 * 1024**2, 400 * 1024**2, 300, 300, 0, 0, 0, 0), + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "400.0" + assert packets_out_sensor.state == "300" + assert float(throughput_network_out_sensor.state) == pytest.approx(3.493, rel=0.1) + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == STATE_UNKNOWN + assert packets_out_sensor.state == STATE_UNKNOWN + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + +async def test_missing_cpu_temperature( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor when temperature missing.""" + mock_psutil.sensors_temperatures.return_value = { + "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert "Cannot read CPU / processor temperature information" in caplog.text + temp_sensor = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_sensor is None + + +async def test_processor_temperature() -> None: + """Test the disk failures.""" + + with patch("sys.platform", "linux"), patch( + "homeassistant.components.systemmonitor.sensor.psutil" + ) as mock_psutil: + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + temperature = _read_cpu_temperature() + assert temperature == 50.0 + + with patch("sys.platform", "nt"), patch( + "homeassistant.components.systemmonitor.sensor.psutil", + ) as mock_psutil: + mock_psutil.sensors_temperatures.side_effect = AttributeError( + "sensors_temperatures not exist" + ) + temperature = _read_cpu_temperature() + assert temperature is None + + with patch("sys.platform", "darwin"), patch( + "homeassistant.components.systemmonitor.sensor.psutil" + ) as mock_psutil: + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + temperature = _read_cpu_temperature() + assert temperature == 50.0 diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py new file mode 100644 index 00000000000..c0c6829a752 --- /dev/null +++ b/tests/components/systemmonitor/test_util.py @@ -0,0 +1,90 @@ +"""Test System Monitor utils.""" + +from unittest.mock import Mock, patch + +from psutil._common import sdiskpart +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (PermissionError("No permission"), "No permission for running user to access"), + (OSError("OS error"), "was excluded because of: OS error"), + ], +) +async def test_disk_setup_failure( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the disk failures.""" + + with patch( + "homeassistant.components.systemmonitor.util.psutil.disk_usage", + side_effect=side_effect, + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free_media_share") + assert disk_sensor is None + + assert error_text in caplog.text + + +async def test_disk_util( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the disk failures.""" + + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), # Should be ok + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), # Should be ok + sdiskpart("test3", "/incorrect", "", "", 1, 1), # Should be skipped as no type + sdiskpart( + "proc", "/proc/run", "proc", "", 1, 1 + ), # Should be skipped as in skipped disk types + sdiskpart( + "test4", + "/tmpfs/", # noqa: S108 + "tmpfs", + "", + 1, + 1, + ), # Should be skipped as in skipped disk types + sdiskpart("test5", "E:", "cd", "cdrom", 1, 1), # Should be skipped as cdrom + ] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor1 = hass.states.get("sensor.system_monitor_disk_free") + disk_sensor2 = hass.states.get("sensor.system_monitor_disk_free_media_share") + disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_incorrect") + disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_proc_run") + disk_sensor5 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") + disk_sensor6 = hass.states.get("sensor.system_monitor_disk_free_e") + assert disk_sensor1 is not None + assert disk_sensor2 is not None + assert disk_sensor3 is None + assert disk_sensor4 is None + assert disk_sensor5 is None + assert disk_sensor6 is None