Files
ledgrab/server/tests/test_metrics_provider.py
T
alexei.dolgolyov 546b24d015
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m20s
refactor(metrics): MetricsProvider abstraction with Android /proc backend
Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.

Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
2026-04-14 13:34:32 +03:00

184 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the metrics provider abstraction."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock, mock_open, patch
import pytest
from ledgrab.utils.metrics import (
AndroidMetricsProvider,
MemorySnapshot,
NullMetricsProvider,
ProcessSnapshot,
PsutilMetricsProvider,
get_metrics_provider,
reset_metrics_provider,
)
from ledgrab.utils.metrics import android_provider as android_mod
@pytest.fixture(autouse=True)
def _reset_provider_cache():
reset_metrics_provider()
yield
reset_metrics_provider()
def test_null_provider_returns_zero_metrics() -> None:
p = NullMetricsProvider()
assert p.available is False
assert p.cpu_percent() == 0.0
assert p.cpu_count() == 1
assert p.virtual_memory() == MemorySnapshot(0, 0, 0.0)
assert p.process_snapshot() == ProcessSnapshot(0.0, 0)
def test_psutil_provider_normalizes_process_cpu() -> None:
psutil_mock = MagicMock()
psutil_mock.cpu_percent.return_value = 42.5
psutil_mock.cpu_count.return_value = 8
mem = MagicMock(used=2_000_000, total=8_000_000, percent=25.0)
psutil_mock.virtual_memory.return_value = mem
proc_mock = MagicMock()
# Per-core 0N*100% — 800% means all 8 cores fully busy → 100% normalized.
proc_mock.cpu_percent.return_value = 800.0
proc_mock.memory_info.return_value = MagicMock(rss=1_500_000)
psutil_mock.Process.return_value = proc_mock
provider = PsutilMetricsProvider(psutil_mock)
# Two priming calls expected at construction (host + process counters).
assert psutil_mock.cpu_percent.call_count == 1
assert proc_mock.cpu_percent.call_count == 1
assert provider.available is True
assert provider.cpu_percent() == 42.5
assert provider.cpu_count() == 8
snap = provider.virtual_memory()
assert snap.used_bytes == 2_000_000
assert snap.total_bytes == 8_000_000
assert snap.percent == 25.0
proc = provider.process_snapshot()
assert proc.cpu_percent == 100.0 # 800% / 8 cores
assert proc.rss_bytes == 1_500_000
def test_psutil_provider_handles_unknown_cpu_count() -> None:
psutil_mock = MagicMock()
psutil_mock.cpu_count.return_value = None # psutil sometimes returns None
psutil_mock.Process.return_value = MagicMock()
provider = PsutilMetricsProvider(psutil_mock)
assert provider.cpu_count() == 1 # falls back to 1 to avoid div-by-zero
def test_factory_returns_psutil_provider_when_available() -> None:
pytest.importorskip("psutil")
provider = get_metrics_provider()
assert isinstance(provider, PsutilMetricsProvider)
assert provider.available is True
# Same instance on subsequent calls — provider is cached.
assert get_metrics_provider() is provider
def test_factory_falls_back_to_null_when_psutil_missing(monkeypatch) -> None:
# Hide psutil from the import system for this test.
monkeypatch.setitem(sys.modules, "psutil", None)
provider = get_metrics_provider()
assert isinstance(provider, NullMetricsProvider)
assert provider.available is False
# ── Android provider ────────────────────────────────────────────────
def test_android_meminfo_parses_kb_values(monkeypatch) -> None:
sample = (
"MemTotal: 2000000 kB\n"
"MemFree: 500000 kB\n"
"MemAvailable: 1500000 kB\n"
)
with patch("builtins.open", mock_open(read_data=sample)):
snap = android_mod._read_meminfo()
assert snap.total_bytes == 2000000 * 1024
# used = total - available
assert snap.used_bytes == 500000 * 1024
assert snap.percent == 25.0
def test_android_meminfo_returns_zero_on_missing_file(monkeypatch) -> None:
def _raise(*args, **kwargs):
raise OSError("simulated")
monkeypatch.setattr("builtins.open", _raise)
snap = android_mod._read_meminfo()
assert snap == MemorySnapshot(0, 0, 0.0)
def test_android_proc_self_stat_parses_with_paren_in_comm() -> None:
# Process name "(weird) name" — embedded parens are the classic /proc trap.
fields = ["S", "1", "1", "1", "0", "-1", "0", "0", "0", "0", "0", "150", "75"]
raw = b"42 ((weird) name) " + " ".join(fields).encode() + b"\n"
m = mock_open(read_data=raw)
with patch("builtins.open", m):
jiffies = android_mod._read_proc_self_stat_jiffies()
assert jiffies == 150 + 75
def test_android_provider_cpu_percent_uses_delta() -> None:
# First sample: total=1000, busy=200. Second sample: total=2000, busy=900.
# Delta busy/total = 700/1000 = 70%.
samples = iter(
[
android_mod._CpuSample(total=1000, busy=200),
android_mod._CpuSample(total=2000, busy=900),
]
)
with patch.object(android_mod, "_read_proc_stat", lambda: next(samples)):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: 0):
provider = AndroidMetricsProvider()
assert provider.cpu_percent() == 70.0
def test_android_provider_process_cpu_normalized_across_cores() -> None:
# Process consumed 400 jiffies while host clock advanced 1000 jiffies
# across all cores → 40% of one CPU's worth of work.
host_samples = iter(
[
android_mod._CpuSample(total=1000, busy=500),
android_mod._CpuSample(total=2000, busy=1500),
]
)
proc_samples = iter([100, 500])
with patch.object(android_mod, "_read_proc_stat", lambda: next(host_samples)):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: next(proc_samples)):
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 12345):
with patch.object(android_mod.os, "cpu_count", lambda: 4):
provider = AndroidMetricsProvider()
snap = provider.process_snapshot()
assert snap.cpu_percent == 40.0
assert snap.rss_bytes == 12345
def test_android_provider_handles_missing_proc_files() -> None:
with patch.object(android_mod, "_read_proc_stat", lambda: None):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: None):
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 0):
provider = AndroidMetricsProvider()
# No samples available → 0.0, not an exception.
assert provider.cpu_percent() == 0.0
snap = provider.process_snapshot()
assert snap == ProcessSnapshot(0.0, 0)
def test_factory_prefers_android_when_running_on_android(monkeypatch) -> None:
monkeypatch.setattr("ledgrab.utils.metrics.is_android", lambda: True)
monkeypatch.setattr("ledgrab.utils.metrics._android_supported", lambda: True)
provider = get_metrics_provider()
assert isinstance(provider, AndroidMetricsProvider)