Files
ledgrab/server/tests/test_metrics_provider.py
T
alexei.dolgolyov ecae05d00b
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 4m18s
feat(metrics): battery + thermal-zone readings with dashboard temp chart
Extends MetricsProvider with thermals() returning a ThermalSnapshot
(battery_percent, battery_temp_c, cpu_temp_c — all optional). Each
provider implements it independently:

- AndroidMetricsProvider reads /sys/class/power_supply/battery/{capacity,
  temp} (battery temp is tenths of degC) and walks
  /sys/class/thermal/thermal_zone*, filtering by zone type
  (cpu/soc/tsens/core) so battery and skin sensors don't dominate the
  reading. Rejects nonsense values like INT_MAX from buggy zones.
- PsutilMetricsProvider uses sensors_battery() and
  sensors_temperatures() when present (Linux+laptops); no-ops on
  Windows/macOS where psutil doesn't expose them.
- NullMetricsProvider returns the empty snapshot.

PerformanceResponse gains battery_percent / battery_temp_c / cpu_temp_c.
The metrics-history ring buffer also carries cpu_temp / battery_pct /
battery_temp per sample so the dashboard can graph them over time.

Frontend dashboard (perf-charts.ts) gets a new Temperature chart card,
hidden by default and revealed only after seed/poll confirms the
backend reports cpu_temp_c. Battery temperature shows inline as a
secondary badge. The GPU card now also hides entirely when the backend
reports gpu=null instead of showing an "unavailable" placeholder.
HOST_ONLY_KEYS prevents the System/App/Both toggle from flipping a
non-existent app dataset for temp.

Tests: 6 new for thermals (battery tenths-of-degC parsing, CPU zone
filtering, fallback when sensors absent, INT_MAX rejection); 18 metrics
tests total; full suite 733 passing.
2026-04-14 13:48:01 +03:00

279 lines
10 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,
ThermalSnapshot,
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)
# ── Thermals ────────────────────────────────────────────────────────
def test_null_provider_thermals_are_all_none() -> None:
snap = NullMetricsProvider().thermals()
assert snap == ThermalSnapshot()
def test_psutil_provider_thermals_picks_hottest_sensor() -> None:
psutil_mock = MagicMock()
psutil_mock.Process.return_value = MagicMock()
psutil_mock.cpu_count.return_value = 4
bat = MagicMock(percent=78.0)
psutil_mock.sensors_battery.return_value = bat
psutil_mock.sensors_temperatures.return_value = {
"coretemp": [
MagicMock(current=55.0),
MagicMock(current=72.5),
],
"acpi": [MagicMock(current=40.0)],
}
provider = PsutilMetricsProvider(psutil_mock)
snap = provider.thermals()
assert snap.battery_percent == 78.0
assert snap.cpu_temp_c == 72.5 # hottest across all sensors
assert snap.battery_temp_c is None # psutil doesn't expose battery temp
def test_psutil_provider_thermals_handles_missing_sensors() -> None:
psutil_mock = MagicMock()
psutil_mock.Process.return_value = MagicMock()
psutil_mock.cpu_count.return_value = 1
# Strip the optional sensor methods entirely (e.g. Windows psutil).
del psutil_mock.sensors_battery
del psutil_mock.sensors_temperatures
provider = PsutilMetricsProvider(psutil_mock)
assert provider.thermals() == ThermalSnapshot()
def test_android_battery_parses_tenths_of_celsius(monkeypatch) -> None:
def _fake_int(path: str):
return {
"/sys/class/power_supply/battery/capacity": 78,
"/sys/class/power_supply/battery/temp": 312, # tenths of °C → 31.2°C
}.get(path)
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
pct, temp = android_mod._read_battery()
assert pct == 78.0
assert temp == 31.2
def test_android_cpu_temp_filters_non_cpu_zones_and_picks_hottest(monkeypatch) -> None:
monkeypatch.setattr(
"glob.glob",
lambda _: [
"/sys/class/thermal/thermal_zone0",
"/sys/class/thermal/thermal_zone1",
"/sys/class/thermal/thermal_zone2",
"/sys/class/thermal/thermal_zone3",
],
)
def _fake_text(path: str):
return {
"/sys/class/thermal/thermal_zone0/type": "battery",
"/sys/class/thermal/thermal_zone1/type": "cpu-thermal",
"/sys/class/thermal/thermal_zone2/type": "soc-max",
"/sys/class/thermal/thermal_zone3/type": "skin-therm",
}.get(path)
def _fake_int(path: str):
return {
# Battery & skin should be filtered out by zone type
"/sys/class/thermal/thermal_zone0/temp": 99000,
"/sys/class/thermal/thermal_zone1/temp": 52000, # 52°C
"/sys/class/thermal/thermal_zone2/temp": 67500, # 67.5°C ← hottest
"/sys/class/thermal/thermal_zone3/temp": 99000,
}.get(path)
monkeypatch.setattr(android_mod, "_read_text_file", _fake_text)
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
assert android_mod._read_cpu_temp_c() == 67.5
def test_android_cpu_temp_rejects_nonsense_values(monkeypatch) -> None:
monkeypatch.setattr("glob.glob", lambda _: ["/sys/class/thermal/thermal_zone0"])
monkeypatch.setattr(android_mod, "_read_text_file", lambda _: "cpu-thermal")
# Some buggy zones report INT_MAX
monkeypatch.setattr(android_mod, "_read_int_file", lambda _: 2147483647)
assert android_mod._read_cpu_temp_c() is None