wrolpi/controller/conftest.py
lrnselfreliance 1bd102c768
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-11 19:46:33 -06:00

152 lines
4.5 KiB
Python

"""
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},
"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 _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