Files
ledgrab/server/tests/test_metrics_provider.py
alexei.dolgolyov e5a2af9821 feat(ui): dashboard polish, richer perf strip, transport-bar controls
Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
  replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
  widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
  to 4 live FPS readouts with channel-colored stripes; bottom-right
  radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
  "fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
  online with signal-glow, coral when offline, tooltip with name +
  latency), fed from /devices/batch/states (added to the dashboard
  batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
  long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
  instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
  align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
  card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
  positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
  drop-shadow filter for the "lit instrument" feel. Chart.js still used
  for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
  not percent — convert to percent using sample's ram_total before
  pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
  history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
  reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
  instead of silently hiding; .perf-chart-card-hint neutralizes the big
  display font so the message reads as plain body copy.

Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
  brandPulse animation. Brand-stack wraps the title + version so
  "LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
  inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
  CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
  readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
  replaces the range slider that used to live in the Dashboard toolbar
  (it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
  glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
  across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
  coral when offline via :has() selector on .header-title.

Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
  name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
  and patch-label + stop button. Running cards get the signal-flow
  strip at the bottom. data-fps-text / data-uptime-text / data-errors-
  text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
  144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
  layout with online/offline patch indicator. Integration card stripe
  uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
  treatment. Automations/scenes dropped into a dashboard-autostart-grid
  so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
  tab-badge / dashboard-section-count badges all use the mono
  rectangular style with tabular-nums.

Command palette:
- Flat background (no gradient), channel-accent rule across the top,
  mono placeholder / group headers / footer, active result gets a
  channel-green left stripe.

Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
  automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
  override.
- Modal header picks up a vertical channel stripe + hairline divider;
  footer gets hairline top + subtle wash.

Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
  focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
  coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
  --lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.

Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
  inside an overflow: hidden / auto / clip ancestor (perf strip, modal
  bodies, tree-dd panels). Prevents the popover getting clipped.

Settings modal:
- Remembers the last-opened tab via localStorage key
  settings_active_tab; falls back to 'general' if the tab id no longer
  exists. Explicit overrides (donation → about, update badge →
  updates) still work because callers invoke switchSettingsTab after
  openSettingsModal.

Microcopy:
- Sidebar / transport localization for en/ru/zh:
  sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
  · transport.status.{ready,armed} · dashboard.perf.{active_patches,
  total_fps,devices}

Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
  temperature is available, so the Temperature card can render an
  actionable explainer instead of being hidden. Frontend respects the
  key via t() lookup.

Section headers:
- Underline switched from dashed to solid; channel-green accent rule
  (40 px) on the left remains.

Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
2026-04-24 20:28:44 +03:00

282 lines
10 KiB
Python
Raw Permalink 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(monkeypatch):
# Disable the Windows CPU-temp background reader so tests don't spawn
# PowerShell when run on a Windows host.
monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1")
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