Files
glances/tests/test_curses_v5.py
2026-05-30 10:59:57 +02:00

707 lines
25 KiB
Python

"""Glances v5 — smoke tests for the curses TUI thread.
The thread is fully exercised under a mocked `curses` module so the suite
runs headless. The visual layer (color attributes, addstr placement) is
checked through assertions on the mock; logic is tested via the pure
renderer in test_curses_renderer_v5.
"""
from __future__ import annotations
import time
from unittest.mock import MagicMock
import pytest
# ---------------------------------------------------------------- fixtures
@pytest.fixture
def fake_store():
store = MagicMock()
store.as_dict.return_value = {
"mem": {
"total": 16_000_000_000,
"available": 8_000_000_000,
"percent": 72.0,
"_levels": {"percent": {"level": "warning", "prominent": True}},
},
}
return store
@pytest.fixture
def fake_alerts():
alerts = MagicMock()
alerts.get_history.return_value = []
return alerts
@pytest.fixture
def fake_config():
cfg = MagicMock()
cfg.get.side_effect = lambda section, key, default=None: default
return cfg
# ---------------------------------------------------------------- lifecycle
def test_tui_v5_can_start_and_stop_without_curses(monkeypatch, fake_store, fake_alerts, fake_config):
"""The thread enters its loop and exits cleanly when stop() is called."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_stdscr = MagicMock()
fake_stdscr.getmaxyx.return_value = (24, 80)
fake_stdscr.getch.return_value = -1
monkeypatch.setattr(tui_mod, "_safe_curses_wrapper", lambda fn: fn(fake_stdscr))
fake_registry = [("mem", False)]
fake_fields = {"mem": {"percent": {"unit": "percent", "label": "MEM", "watched": True, "prominent": True}}}
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=fake_registry,
fields_by_plugin=fake_fields,
refresh_interval=0.01,
)
tui.start()
time.sleep(0.05)
tui.stop()
tui.join(timeout=1.0)
assert not tui.is_alive()
def test_tui_v5_calls_addstr_for_rendered_cells(monkeypatch, fake_store, fake_alerts, fake_config):
"""The thread paints something onto stdscr each cycle."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_stdscr = MagicMock()
fake_stdscr.getmaxyx.return_value = (24, 80)
fake_stdscr.getch.return_value = -1
def record_wrapper(fn):
fn(fake_stdscr)
monkeypatch.setattr(tui_mod, "_safe_curses_wrapper", record_wrapper)
registry = [("mem", False)]
fields = {"mem": {"percent": {"unit": "percent", "label": "MEM", "watched": True, "prominent": True}}}
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=registry,
fields_by_plugin=fields,
refresh_interval=0.01,
)
tui.start()
time.sleep(0.05)
tui.stop()
tui.join(timeout=1.0)
addstr_calls = list(fake_stdscr.addstr.call_args_list)
assert addstr_calls, "addstr was never called"
flat = " ".join(str(args) for args in addstr_calls)
assert "MEM" in flat
def test_paint_sidebar_advances_y_by_block_height_plus_one_blank_line(fake_store, fake_alerts, fake_config):
"""Regression: ``_paint_sidebar`` used to pass the return of
``_paint_block`` (the WIDTH painted, ~34 chars) as a height, leaving a
huge gap between sidebar blocks (network → fs would skip ~35 lines).
The fix advances ``y`` by ``block.height + 1`` instead — one blank
line between blocks, matching v4 sidebar layout.
"""
from glances.outputs import glances_curses_v5 as tui_mod
from glances.outputs.curses_renderer_v5 import Cell, PluginBlock, Row
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
# Two blocks of distinct, known heights.
block_a = PluginBlock(
name="network",
rows=[Row(cells=[Cell(text="NETWORK")]), Row(cells=[Cell(text="eth0")])],
) # height = 2
block_b = PluginBlock(
name="fs",
rows=[Row(cells=[Cell(text="FILE SYS")]), Row(cells=[Cell(text="/")])],
) # height = 2
fake_stdscr = MagicMock()
tui._paint_sidebar(fake_stdscr, [block_a, block_b], y0=5, x0=0, width=34, height=20)
# Collect every (y, text) addstr call.
rows_painted = [(call.args[0], call.args[2]) for call in fake_stdscr.addstr.call_args_list]
# Block A rendered at y=5, y=6. Block B at y=5+2+1=8, y=9. y=7 must be empty.
ys = sorted({y for y, _ in rows_painted})
assert ys == [5, 6, 8, 9], f"unexpected y-coordinates: {ys}"
# And there's no row painted at y=7 (the blank separator line).
assert all(y != 7 for y, _ in rows_painted)
def test_tui_v5_quit_on_q_key(monkeypatch, fake_store, fake_alerts, fake_config):
"""Pressing 'q' triggers stop()."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_stdscr = MagicMock()
fake_stdscr.getmaxyx.return_value = (24, 80)
fake_stdscr.getch.side_effect = [ord("q"), -1, -1]
monkeypatch.setattr(tui_mod, "_safe_curses_wrapper", lambda fn: fn(fake_stdscr))
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("mem", False)],
fields_by_plugin={"mem": {}},
refresh_interval=0.01,
)
tui.start()
tui.join(timeout=1.0)
assert not tui.is_alive()
assert tui._stop_event.is_set()
def test_attr_for_prominent_ok_uses_reverse():
"""A prominent cell with OK level renders with background highlight."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="50%", color=ColorRole.OK, prominent=True)
attr = _attr_for(cell)
assert attr & curses.A_REVERSE
def test_attr_for_prominent_warning_uses_reverse():
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="80%", color=ColorRole.WARNING, prominent=True)
attr = _attr_for(cell)
assert attr & curses.A_REVERSE
def test_attr_for_non_prominent_warning_does_not_use_reverse():
"""Non-prominent cells never get A_REVERSE, even at WARNING/CRITICAL."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="80%", color=ColorRole.WARNING, prominent=False)
attr = _attr_for(cell)
assert not (attr & curses.A_REVERSE)
def test_attr_for_prominent_default_color_stays_plain():
"""`prominent` only matters when an alert level is set."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="", color=ColorRole.DEFAULT, prominent=True)
attr = _attr_for(cell)
assert not (attr & curses.A_REVERSE)
def test_attr_for_explicit_bold_flag_applies_a_bold():
"""A non-HEADER cell with `bold=True` still gets A_BOLD (used for
alert-coloured plugin titles)."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="MEM", color=ColorRole.CRITICAL, bold=True)
attr = _attr_for(cell)
assert attr & curses.A_BOLD
def test_attr_for_header_is_bold_without_explicit_flag():
"""Backwards compat: HEADER role implies bold even without `bold=True`."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
cell = Cell(text="MEM", color=ColorRole.HEADER)
attr = _attr_for(cell)
assert attr & curses.A_BOLD
def test_attr_for_prominent_uses_dedicated_reverse_pair_when_available(monkeypatch):
"""When `_init_colors` has populated `_COLOR_PAIRS_REVERSE`, prominent
cells use the dedicated white-on-colour pair instead of A_REVERSE on
the foreground pair — matching v4 readability for *_LOG decorations."""
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
# Inject sentinel attr values into the module-level dicts so we can
# observe which path `_attr_for` took.
monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS", {ColorRole.WARNING: 0xCAFE})
monkeypatch.setattr(
"glances.outputs.glances_curses_v5._COLOR_PAIRS_REVERSE",
{ColorRole.WARNING: 0xBEEF},
)
cell = Cell(text="80%", color=ColorRole.WARNING, prominent=True)
attr = _attr_for(cell)
# The reverse-pair sentinel is in the attr.
assert attr & 0xBEEF == 0xBEEF
# The foreground-pair sentinel is NOT used.
assert not (attr & 0xCAFE == 0xCAFE)
def test_top_row_gaps_evenly_distributes_remaining_space(fake_store, fake_alerts, fake_config):
"""3 blocks of widths [10, 15, 12] in a 60-col terminal:
total=37, available=23, 2 gaps → 12 + 11 (extra char to the leftmost).
First block flush-left; last block's right edge at column 59."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
gaps = tui._top_row_gaps([10, 15, 12], max_x=60)
assert sum(gaps) + 10 + 15 + 12 == 60
assert gaps == [12, 11]
def test_top_row_gaps_handles_single_block(fake_store, fake_alerts, fake_config):
"""One block alone has no gaps (and is flush-left)."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
assert tui._top_row_gaps([20], max_x=80) == []
def test_top_row_gaps_handles_empty_input(fake_store, fake_alerts, fake_config):
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
assert tui._top_row_gaps([], max_x=80) == []
def test_top_row_gaps_falls_back_to_min_gap_when_no_room(fake_store, fake_alerts, fake_config):
"""When the terminal is narrower than the natural content + min gaps,
every gap collapses to the minimum so curses can clip the overflow
rather than overlap blocks."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
# 3 blocks of widths [30, 30, 30] in 50 cols — way too narrow.
gaps = tui._top_row_gaps([30, 30, 30], max_x=50)
assert gaps == [tui_mod.TuiV5._TOP_GAP_MIN, tui_mod.TuiV5._TOP_GAP_MIN]
def test_top_row_gaps_distributes_evenly_when_remainder_is_zero(fake_store, fake_alerts, fake_config):
"""If available % n_gaps == 0, every gap is identical."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
# 4 blocks [10, 10, 10, 10] = 40; 80 - 40 = 40 / 3 gaps = 13 r1 (one extra)
# Pick an exact divisor: 3 gaps and available=30 → 10/10/10
gaps = tui._top_row_gaps([10, 10, 10, 10], max_x=70)
assert gaps == [10, 10, 10]
def test_attr_for_prominent_falls_back_to_reverse_when_pair_unallocated(monkeypatch):
"""If the white-on-colour pair couldn't be allocated (limited
palette), `_attr_for` falls back to A_REVERSE on the foreground
pair so the cell is still visibly highlighted."""
import curses
from glances.outputs.curses_renderer_v5 import Cell, ColorRole
from glances.outputs.glances_curses_v5 import _attr_for
monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS_REVERSE", {})
cell = Cell(text="80%", color=ColorRole.WARNING, prominent=True)
attr = _attr_for(cell)
assert attr & curses.A_REVERSE
def test_tui_v5_default_top_shows_cpu_not_percpu(monkeypatch, fake_store, fake_alerts, fake_config):
"""At startup, cpu is in the top slot and percpu is hidden (v4 default)."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_store.as_dict.return_value = {
"cpu": {"total": 5.0, "_levels": {}},
"percpu": {"data": [], "_levels": {}},
}
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("cpu", False), ("percpu", True)],
fields_by_plugin={
"cpu": {"total": {"unit": "percent", "watched": True, "label": "CPU"}},
"percpu": {"cpu_number": {"unit": "number", "primary_key": True}},
},
refresh_interval=0.01,
)
frame = tui._build_frame()
top_names = [b.name for b in frame.top]
assert "cpu" in top_names
assert "percpu" not in top_names
def test_tui_v5_toggle_swaps_cpu_for_percpu(monkeypatch, fake_store, fake_alerts, fake_config):
"""Once `_view.show_percpu` flips True, the top slot exposes percpu instead of cpu."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_store.as_dict.return_value = {
"cpu": {"total": 5.0, "_levels": {}},
"percpu": {"data": [], "_levels": {}},
}
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("cpu", False), ("percpu", True)],
fields_by_plugin={
"cpu": {"total": {"unit": "percent", "watched": True, "label": "CPU"}},
"percpu": {"cpu_number": {"unit": "number", "primary_key": True}},
},
refresh_interval=0.01,
)
tui._view.show_percpu = True
frame = tui._build_frame()
top_names = [b.name for b in frame.top]
assert "percpu" in top_names
assert "cpu" not in top_names
def test_tui_v5_hotkey_1_toggles_percpu(monkeypatch, fake_store, fake_alerts, fake_config):
"""Pressing '1' flips `_view.show_percpu`."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_stdscr = MagicMock()
fake_stdscr.getmaxyx.return_value = (24, 80)
# Sequence: one '1' keypress to toggle, then a 'q' to exit.
fake_stdscr.getch.side_effect = [ord("1"), ord("q"), -1, -1]
monkeypatch.setattr(tui_mod, "_safe_curses_wrapper", lambda fn: fn(fake_stdscr))
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("mem", False)],
fields_by_plugin={"mem": {}},
refresh_interval=0.01,
)
assert tui._view.show_percpu is False
tui.start()
tui.join(timeout=1.0)
# After one '1' press, the flag was flipped — the thread exits on 'q'
# but the flag retains the toggled value.
assert tui._view.show_percpu is True
class _FakeEngine:
"""Minimal ``glances_processes`` stand-in recording sort-key changes."""
def __init__(self) -> None:
self.calls: list[tuple[str, bool]] = []
self.sort_key = "cpu_percent"
self.auto_sort = False
def set_sort_key(self, key, auto) -> None:
self.calls.append((key, auto))
self.sort_key = "cpu_percent" if key == "auto" else key
self.auto_sort = (key == "auto") or auto
def _make_tui(tui_mod, fake_store, fake_alerts, fake_config, **kw):
return tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=kw.get("registry", [("mem", False)]),
fields_by_plugin=kw.get("fields_by_plugin", {"mem": {}}),
refresh_interval=0.01,
)
def test_tui_v5_handle_key_quit(fake_store, fake_alerts, fake_config):
"""`q` and ESC request shutdown; any other key does not."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
assert tui._handle_key(ord("q")) == "quit"
assert tui._handle_key(27) == "quit"
assert tui._handle_key(ord("z")) == "ignored"
def test_tui_v5_sort_hotkeys_drive_engine(monkeypatch, fake_store, fake_alerts, fake_config):
"""Manual sort keys set the engine key with auto=False; 'a' enables auto."""
from glances.outputs import glances_curses_v5 as tui_mod
engine = _FakeEngine()
monkeypatch.setattr(tui_mod, "glances_processes", engine)
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
for ch, expected in [
("c", "cpu_percent"),
("m", "memory_percent"),
("i", "io_counters"),
("t", "cpu_times"),
("p", "name"),
("u", "username"),
("o", "cpu_num"),
]:
assert tui._handle_key(ord(ch)) == "changed"
assert engine.calls[-1] == (expected, False)
assert tui._handle_key(ord("a")) == "changed"
assert engine.calls[-1] == ("auto", True)
assert engine.auto_sort is True
def test_tui_v5_switch_hotkeys_toggle_view(fake_store, fake_alerts, fake_config):
"""`/` toggles short-name, `j` toggles the programs view."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
assert tui._view.process_short_name is True
assert tui._handle_key(ord("/")) == "changed"
assert tui._view.process_short_name is False
assert tui._view.programs is False
assert tui._handle_key(ord("j")) == "changed"
assert tui._view.programs is True
def test_tui_v5_unknown_key_is_noop(fake_store, fake_alerts, fake_config):
"""An unmapped key leaves view state untouched and does not quit."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
before = (tui._view.show_percpu, tui._view.process_short_name, tui._view.programs)
assert tui._handle_key(ord("Z")) == "ignored"
after = (tui._view.show_percpu, tui._view.process_short_name, tui._view.programs)
assert before == after
def test_tui_v5_programs_toggle_hides_one_list(fake_store, fake_alerts, fake_config):
"""`j` shows exactly one of processlist / programlist in the right slot."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_store.as_dict.return_value = {
"processlist": {"data": [], "_levels": {}},
"programlist": {"data": [], "_levels": {}},
}
tui = _make_tui(
tui_mod,
fake_store,
fake_alerts,
fake_config,
registry=[("processlist", True), ("programlist", True)],
fields_by_plugin={"processlist": {}, "programlist": {}},
)
# Default (threads view): programlist hidden, processlist shown.
names = [b.name for b in tui._build_frame().right]
assert "processlist" in names
assert "programlist" not in names
# Programs view: the reverse.
tui._view.programs = True
names = [b.name for b in tui._build_frame().right]
assert "programlist" in names
assert "processlist" not in names
def test_tui_v5_render_view_snapshots_engine_sort(monkeypatch, fake_store, fake_alerts, fake_config):
"""`_render_view` exposes engine sort key + view switches to renderers."""
from glances.outputs import glances_curses_v5 as tui_mod
engine = _FakeEngine()
engine.sort_key = "memory_percent"
engine.auto_sort = True
monkeypatch.setattr(tui_mod, "glances_processes", engine)
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
tui._view.process_short_name = False
tui._view.programs = True
view = tui._render_view()
assert view["sort_key"] == "memory_percent"
assert view["auto_sort"] is True
assert view["process_short_name"] is False
assert view["programs"] is True
def test_tui_v5_repaint_decision_guard_rail(fake_store, fake_alerts, fake_config):
"""A pending key change repaints at most once per `_MIN_KEY_REPAINT_INTERVAL`
(the guard-rail), measured from the last key-driven repaint."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
interval = tui._MIN_KEY_REPAINT_INTERVAL
# A change 0.5 s after the last key repaint → throttled (not yet due).
_, change_due = tui._repaint_decision(
now=100.0, last_paint=100.0, last_change_paint=100.0 - 0.5 * interval, dirty=True
)
assert change_due is False
# A change a full interval later → due.
_, change_due = tui._repaint_decision(now=100.0, last_paint=100.0, last_change_paint=100.0 - interval, dirty=True)
assert change_due is True
# No pending change → never change-due regardless of elapsed time.
_, change_due = tui._repaint_decision(now=100.0, last_paint=100.0, last_change_paint=0.0, dirty=False)
assert change_due is False
def test_tui_v5_repaint_decision_regular_cadence(fake_store, fake_alerts, fake_config):
"""Regular cadence is independent of key changes."""
from glances.outputs import glances_curses_v5 as tui_mod
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
# last paint older than refresh_interval → regular due.
regular_due, _ = tui._repaint_decision(
now=100.0, last_paint=100.0 - tui.refresh_interval, last_change_paint=100.0, dirty=False
)
assert regular_due is True
# last paint just now → not due.
regular_due, _ = tui._repaint_decision(now=100.0, last_paint=100.0, last_change_paint=0.0, dirty=False)
assert regular_due is False
def test_tui_v5_live_sort_reorders_by_engine_key(monkeypatch, fake_store, fake_alerts, fake_config):
"""`_apply_live_sort` reorders process data by the engine's current key so
a sort hotkey is reflected on the next repaint (not the next engine tick)."""
from glances.outputs import glances_curses_v5 as tui_mod
engine = _FakeEngine()
engine.sort_key = "memory_percent" # reverse=True default
monkeypatch.setattr(tui_mod, "glances_processes", engine)
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
snapshot = {
"processlist": {
"data": [
{"pid": 1, "cpu_percent": 90.0, "memory_percent": 1.0, "name": "a"},
{"pid": 2, "cpu_percent": 1.0, "memory_percent": 90.0, "name": "b"},
],
"_levels": {},
}
}
tui._apply_live_sort(snapshot)
pids = [p["pid"] for p in snapshot["processlist"]["data"]]
assert pids == [2, 1] # memory_percent descending
def test_tui_v5_live_sort_does_not_mutate_store_payload(monkeypatch, fake_store, fake_alerts, fake_config):
"""The shallow store snapshot must not be mutated — the entry is replaced
by a fresh dict with a freshly sorted list, leaving the original intact."""
from glances.outputs import glances_curses_v5 as tui_mod
engine = _FakeEngine()
engine.sort_key = "memory_percent"
monkeypatch.setattr(tui_mod, "glances_processes", engine)
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
original_payload = {
"data": [
{"pid": 1, "cpu_percent": 90.0, "memory_percent": 1.0, "name": "a"},
{"pid": 2, "cpu_percent": 1.0, "memory_percent": 90.0, "name": "b"},
],
"_levels": {},
}
original_data = original_payload["data"]
snapshot = {"processlist": original_payload}
tui._apply_live_sort(snapshot)
# Snapshot entry replaced (not the same object) but the original payload
# and its list keep their original order.
assert snapshot["processlist"] is not original_payload
assert [p["pid"] for p in original_data] == [1, 2]
def test_tui_v5_live_sort_noop_without_key(monkeypatch, fake_store, fake_alerts, fake_config):
from glances.outputs import glances_curses_v5 as tui_mod
engine = _FakeEngine()
engine.sort_key = None
monkeypatch.setattr(tui_mod, "glances_processes", engine)
tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config)
payload = {"data": [{"pid": 1, "cpu_percent": 1.0, "memory_percent": 1.0, "name": "a"}], "_levels": {}}
snapshot = {"processlist": payload}
tui._apply_live_sort(snapshot)
assert snapshot["processlist"] is payload # untouched
def test_tui_v5_q_key_fires_on_quit_callback(monkeypatch, fake_store, fake_alerts, fake_config):
"""Pressing 'q' fires the on_quit callback so the main loop can shut down."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_stdscr = MagicMock()
fake_stdscr.getmaxyx.return_value = (24, 80)
fake_stdscr.getch.side_effect = [ord("q"), -1, -1]
monkeypatch.setattr(tui_mod, "_safe_curses_wrapper", lambda fn: fn(fake_stdscr))
fired: list[bool] = []
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("mem", False)],
fields_by_plugin={"mem": {}},
refresh_interval=0.01,
on_quit=lambda: fired.append(True),
)
tui.start()
tui.join(timeout=1.0)
assert fired == [True]