wrolpi/controller/conftest.py

161 lines
4.8 KiB
Python
Raw Normal View History

"""
Pytest fixtures for Controller tests.
"""
import asyncio
import copy
import tempfile
from pathlib import Path
from typing import Generator
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from controller.defaults import DEFAULT_CONFIG
_FAKE_CACHED_STATUS = {
"cpu_stats": {
"percent": 0.0, "cores": 1,
"cur_frequency": 0, "max_frequency": 0, "min_frequency": 0,
"temperature": None, "high_temperature": None, "critical_temperature": None,
},
"memory_stats": {"total": 1, "used": 0, "free": 1, "cached": 0},
"load_stats": {"minute_1": "0.00", "minute_5": "0.00", "minute_15": "0.00"},
"drives_stats": [],
"nic_bandwidth_stats": {},
"power_stats": {"under_voltage": False, "over_current": False},
"iostat_stats": {"percent_iowait": None},
"processes_stats": [],
"disk_bandwidth_stats": {},
"uptime_stats": {"uptime_seconds": 0},
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
"smart_stats": {"drives": [], "high_temperature": 55, "critical_temperature": 65},
"last_status": "1970-01-01T00:00:00",
}
async def _noop_status_worker(app, base_sleep: float = 5.0):
"""Replacement for status_worker_loop during tests.
The real worker calls blocking samplers (psutil with interval=1.0, iostat)
via asyncio.to_thread; those threads can't be cancelled, so TestClient
teardown waits ~2s for each test. Also pre-populates cached_status so the
dashboard route doesn't fall back to direct sampling on the request path.
"""
app.state.cached_status = dict(_FAKE_CACHED_STATUS)
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise
@pytest.fixture(autouse=True)
def _isolate_media_directory(tmp_path_factory, monkeypatch):
"""Point the media directory at an empty temp dir so tests never read a real
/media/wrolpi (e.g. hotspot settings from wrolpi.yaml)."""
media_dir = tmp_path_factory.mktemp("media")
monkeypatch.setenv("MEDIA_DIRECTORY", str(media_dir))
@pytest.fixture(autouse=True)
def _patch_status_worker():
"""Replace the real status worker with a no-op for every test.
See _noop_status_worker for why this is necessary.
"""
with mock.patch("controller.main.status_worker_loop", _noop_status_worker):
yield
@pytest.fixture
def test_directory() -> Generator[Path, None, None]:
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir).resolve()
tmp_path.chmod(0o40755)
yield tmp_path
@pytest.fixture
def test_config_directory(test_directory: Path) -> Path:
"""Create a config directory inside test_directory."""
config_dir = test_directory / "config"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
@pytest.fixture
def mock_config_path(test_config_directory: Path):
"""Mock the CONFIG_PATH_ON_DRIVE to use test directory."""
config_path = test_config_directory / "controller.yaml"
with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path):
yield config_path
@pytest.fixture
def reset_runtime_config():
"""Reset the runtime config to defaults before and after each test."""
import controller.lib.config as config_module
# Save original
original = copy.deepcopy(config_module._runtime_config)
# Reset to defaults
config_module._runtime_config = copy.deepcopy(DEFAULT_CONFIG)
yield
# Restore original
config_module._runtime_config = original
@pytest.fixture
def mock_docker_mode():
"""Mock Docker mode to be disabled."""
with mock.patch.dict("os.environ", {"DOCKERIZED": "false"}):
yield
@pytest.fixture
def mock_docker_mode_enabled():
"""Mock Docker mode to be enabled."""
with mock.patch.dict("os.environ", {"DOCKERIZED": "true"}):
yield
@pytest.fixture
def mock_drive_mounted(test_config_directory: Path):
"""Mock the primary drive as mounted by creating config directory."""
# test_config_directory fixture already creates it
with mock.patch(
"controller.lib.config.is_primary_drive_mounted", return_value=True
):
yield test_config_directory
@pytest.fixture
def mock_drive_not_mounted():
"""Mock the primary drive as not mounted."""
with mock.patch(
"controller.lib.config.is_primary_drive_mounted", return_value=False
):
yield
@pytest.fixture
def test_client(reset_runtime_config, mock_docker_mode) -> TestClient:
"""Create a FastAPI TestClient for integration testing."""
from controller.main import app
with TestClient(app) as client:
yield client
@pytest.fixture
def test_client_docker_mode(reset_runtime_config, mock_docker_mode_enabled) -> TestClient:
"""Create a FastAPI TestClient with Docker mode enabled."""
from controller.main import app
with TestClient(app) as client:
yield client