Files
glances/tests/test_plugin_memswap_v5.py
nicolargo fa7fc0c99d fix(v5): thresholds — strict_thresholds flag isolates opt-in fields
Root cause of the "sin/sout shown in green" report: a v4-era user
XDG ``~/.config/glances/glances.conf`` carrying bare keys
``[memswap] careful=50 / warning=70 / critical=90`` gets merged with
the v5 project conf via the config layering. ``read_thresholds`` then
falls back to those bare keys for ANY watched field in the section —
including the v5-only opt-in sin/sout — so empty ``_levels`` was
never reachable in practice.

Fix: introduce a per-field schema flag ``strict_thresholds: True``
that opts the field out of the bare-``<level>`` fallback inside
``read_thresholds``. Only field-prefixed (``sin_warning``) and pk-
field-level keys still activate it.

- ``read_thresholds`` gains ``strict: bool = False`` parameter.
- ``_compute_levels_for_item`` propagates ``schema.get("strict_thresholds")``.
- ``memswap.sin`` and ``memswap.sout`` ship ``strict_thresholds: True``.
  ``memswap.percent`` keeps the legacy fallback (consistent with v4
  ``[memswap] careful=50`` applying to the swap percent).

5 new tests:
- 4 in ``test_thresholds_v5.py``: strict skips bare for scalar and
  collection cases; strict still honours field-prefixed and
  pk-field-level keys.
- 1 in ``test_plugin_memswap_v5.py``: end-to-end with a fake conf
  containing only bare keys → percent gets a _levels entry, sin/sout
  do not.

Full v5 suite: 701 passed (+5), lint clean.

Runtime check with the real user-XDG layering reproduces the fix:
``memswap _levels: {'percent': {'level': 'ok', 'prominent': True}}``
— sin/sout absent → renderer paints them in DEFAULT colour, not
green.
2026-05-15 16:52:30 +02:00

300 lines
11 KiB
Python

#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — unit tests for the `memswap` plugin (scalar)."""
from __future__ import annotations
from collections import namedtuple
from unittest.mock import patch
import pytest
from glances.config_v5 import GlancesConfigV5
from glances.plugins.memswap.model_v5 import PluginModel
from glances.stats_store_v5 import StatsStoreV5
# psutil result stub ------------------------------------------------------
SwapMemory = namedtuple("sswap", ["total", "used", "free", "percent", "sin", "sout"])
def _swap(percent: float = 25.0, sin: int = 0, sout: int = 0) -> SwapMemory:
total = 16 * 1024**3 # 16 GiB
used = int(total * percent / 100.0)
return SwapMemory(total=total, used=used, free=total - used, percent=percent, sin=sin, sout=sout)
# ----------------------------------------------------------- fixtures
@pytest.fixture
def store() -> StatsStoreV5:
return StatsStoreV5()
@pytest.fixture
def config(tmp_path, monkeypatch) -> GlancesConfigV5:
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
return GlancesConfigV5()
def _config_with(tmp_path, monkeypatch, body: str) -> GlancesConfigV5:
monkeypatch.setattr(GlancesConfigV5, "SYSTEM_CONFIG_PATH", tmp_path / "etc" / "glances.conf")
xdg = tmp_path / "xdg"
cfg_dir = xdg / "glances"
cfg_dir.mkdir(parents=True)
(cfg_dir / "glances.conf").write_text(body)
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
return GlancesConfigV5()
# ---------------------------------------------------------- contract
def test_plugin_identity(store, config):
plugin = PluginModel(store, config)
assert plugin.plugin_name == "memswap"
assert plugin.IS_COLLECTION is False
def test_percent_is_watched_prominent(store, config):
schema = PluginModel(store, config)._fields["percent"]
assert schema["watched"] is True
assert schema["prominent"] is True
assert schema["unit"] == "percent"
def test_total_used_free_are_bytes_not_watched(store, config):
fields = PluginModel(store, config)._fields
for name in ("total", "used", "free"):
assert fields[name]["unit"] == "bytes", name
assert fields[name].get("watched", False) is False, name
def test_sin_sout_are_rate_counters(store, config):
"""sin/sout are cumulative on psutil — v5 converts them to bytes/s."""
fields = PluginModel(store, config)._fields
for name in ("sin", "sout"):
assert fields[name].get("rate") is True, name
def test_sin_sout_are_watched_without_default_thresholds(store, config):
"""sin/sout are opt-in alerts — watched=True so the framework computes
levels when the user sets thresholds in glances.conf, but no
``default_thresholds`` so the field stays silent on a stock install."""
fields = PluginModel(store, config)._fields
for name in ("sin", "sout"):
assert fields[name].get("watched") is True, name
assert "default_thresholds" not in fields[name], name
def test_sin_sout_are_not_prominent(store, config):
"""sin/sout colour only the foreground when an alert fires — they must
NEVER reverse-video the cell. The framework defaults missing
``prominent`` to True, so this requires an *explicit* False in the
schema."""
fields = PluginModel(store, config)._fields
for name in ("sin", "sout"):
assert fields[name].get("prominent") is False, name
async def test_sin_sout_ignore_bare_level_keys_in_memswap_section(tmp_path, monkeypatch, store):
"""Regression guard: a legacy ``[memswap] careful=50`` (no field prefix)
must NOT spill onto sin/sout. The ``strict_thresholds=True`` flag on
these fields skips the bare-level fallback in ``read_thresholds``.
Real-world trigger: a v4-era user XDG glances.conf carries
``careful``/``warning``/``critical`` bare keys that get merged with
the v5 project conf via the config layering."""
config = _config_with(tmp_path, monkeypatch, "[memswap]\ncareful=50\nwarning=70\ncritical=90\n")
plugin = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch.setattr(base_module.time, "monotonic", lambda: fake_now[0])
psutil_path = "glances.plugins.memswap.model_v5.psutil.swap_memory"
with patch(psutil_path, return_value=_swap(sin=0, sout=0)):
await plugin.update()
fake_now[0] = 101.0
with patch(psutil_path, return_value=_swap(sin=1_000, sout=1_000)):
await plugin.update()
levels = store.get("memswap")["_levels"]
# `percent` still picks up the bare keys (it does not opt out of the
# legacy fallback — preserves existing behaviour).
assert "percent" in levels
# sin/sout MUST NOT inherit the bare-level keys.
assert "sin" not in levels
assert "sout" not in levels
async def test_sin_threshold_from_config_carries_non_prominent_level(tmp_path, monkeypatch, store):
"""Even when the user configures thresholds in glances.conf, the resulting
_levels entry must carry ``prominent=False`` — sin/sout are coloured but
never highlighted."""
config = _config_with(tmp_path, monkeypatch, "[memswap]\nsin_careful=5000\nsin_warning=10000\nsin_critical=20000\n")
plugin = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch.setattr(base_module.time, "monotonic", lambda: fake_now[0])
psutil_path = "glances.plugins.memswap.model_v5.psutil.swap_memory"
with patch(psutil_path, return_value=_swap(sin=0)):
await plugin.update()
fake_now[0] = 101.0
with patch(psutil_path, return_value=_swap(sin=15_000)):
await plugin.update()
levels = store.get("memswap")["_levels"]
assert levels["sin"]["level"] == "warning"
assert levels["sin"]["prominent"] is False
async def test_sin_sout_no_levels_without_user_thresholds(store, config):
"""With no [memswap] sin_*/sout_* config keys, no level entry must
appear for sin/sout in the store payload."""
plugin = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch_target = base_module.time
original_monotonic = monkeypatch_target.monotonic
monkeypatch_target.monotonic = lambda: fake_now[0]
try:
psutil_path = "glances.plugins.memswap.model_v5.psutil.swap_memory"
with patch(psutil_path, return_value=_swap(sin=0, sout=0)):
await plugin.update()
fake_now[0] = 101.0
with patch(psutil_path, return_value=_swap(sin=1_000_000, sout=500_000)):
await plugin.update()
finally:
monkeypatch_target.monotonic = original_monotonic
payload = store.get("memswap")
levels = payload.get("_levels", {})
assert "sin" not in levels
assert "sout" not in levels
async def test_sin_threshold_from_config_triggers_level(tmp_path, monkeypatch, store):
"""User-set ``[memswap] sin_warning=10000`` makes sin alert at that level."""
config = _config_with(tmp_path, monkeypatch, "[memswap]\nsin_careful=5000\nsin_warning=10000\nsin_critical=20000\n")
plugin = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch.setattr(base_module.time, "monotonic", lambda: fake_now[0])
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=0)):
await plugin.update()
fake_now[0] = 101.0
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=15_000)):
# 15_000 bytes / 1s = 15_000 bytes/s → between warning (10000) and critical (20000) → warning
await plugin.update()
payload = store.get("memswap")
assert payload["_levels"]["sin"]["level"] == "warning"
# ---------------------------------------------------------- update pipeline
async def test_update_writes_swap_fields(store, config):
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=30.0)):
await plugin.update()
payload = store.get("memswap")
assert payload["percent"] == 30.0
assert payload["total"] == 16 * 1024**3
assert payload["used"] > 0
assert payload["free"] > 0
async def test_update_handles_no_swap_configured(store, config):
"""Illumos / OpenBSD without a swap file: psutil raises — store must not crash."""
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", side_effect=OSError("no swap")):
await plugin.update()
# An empty payload is acceptable — the store key may be absent or empty.
payload = store.get("memswap")
assert payload is None or payload == {} or payload.get("total") is None
async def test_first_cycle_strips_rate_fields(store, config):
"""sin/sout absent on cycle 1 — no previous sample to diff against."""
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap()):
await plugin.update()
payload = store.get("memswap")
assert "sin" not in payload
assert "sout" not in payload
async def test_second_cycle_computes_swap_rates(store, config, monkeypatch):
"""Second cycle: sin/sout become per-second rates."""
plugin = PluginModel(store, config)
fake_now = [100.0]
import glances.plugins.plugin.base_v5 as base_module
monkeypatch.setattr(base_module.time, "monotonic", lambda: fake_now[0])
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=1_000_000, sout=500_000)):
await plugin.update()
fake_now[0] = 101.0 # +1 s elapsed
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(sin=2_000_000, sout=600_000)):
await plugin.update()
payload = store.get("memswap")
# delta = 1_000_000 / 1s = 1_000_000 bytes/s
assert payload["sin"] == 1_000_000.0
# delta = 100_000 / 1s = 100_000 bytes/s
assert payload["sout"] == 100_000.0
# ---------------------------------------------------------- _levels
async def test_percent_level_uses_default_thresholds(store, config):
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=85.0)):
await plugin.update()
# 85 between warning (80) and critical (95) → warning, prominent.
# Default ladder for swap is 60/80/95 (looser than mem's 50/70/90).
assert store.get("memswap")["_levels"]["percent"] == {"level": "warning", "prominent": True}
async def test_percent_level_ok_when_low(store, config):
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap(percent=5.0)):
await plugin.update()
assert store.get("memswap")["_levels"]["percent"]["level"] == "ok"
# ---------------------------------------------------------- export
async def test_get_export_strips_internals_and_levels(store, config):
plugin = PluginModel(store, config)
with patch("glances.plugins.memswap.model_v5.psutil.swap_memory", return_value=_swap()):
await plugin.update()
exported = plugin.get_export()
assert "_levels" not in exported
assert "time_since_update" not in exported
assert exported["percent"] == 25.0