Files
eco/tests/test_status_server_gui.py
lemke_handClaude Sonnet 5 83665caea7 Stop the GUI reinit button from blocking the window from closing
_start_reinit called client.reinit(wait=True), which keeps
_ReinitWorker running for as long as the server-side rebuild takes
(minutes) - and closeEvent refuses to close while that worker is
running, to avoid destroying a QThread mid-flight (a real crash, not
hypothetical). Together that made the window impossible to close for
the whole duration of a reinit, even though the GUI only monitors the
server and the rebuild proceeds independently of whether the window
is even open.

Switch to wait=False: the worker now only has to make the request
itself (bounded by the client's own timeout, normally under a
second), not wait for the rebuild. Progress during the rebuild is
unaffected - it was always coming from the already-running _Poller's
/health polling, not from this worker. closeEvent's guard stays as a
safety net for a slow/unreachable request, just no longer trips for
the reinit itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:32:58 +02:00

217 lines
7.0 KiB
Python

"""eco.status_server.gui: the display/state logic, driven directly (no real
poller thread, no real server) - _on_health/_on_stats/_start_reinit are
plain slots, so they can be called synchronously with fake payloads."""
import pytest
pytest.importorskip("qtpy")
from qtpy import QtGui, QtWidgets
from eco.status_server.gui import COLOR_BAD, COLOR_OK, StatusServerMonitor
@pytest.fixture(scope="module")
def qapp():
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
@pytest.fixture
def win(qapp, monkeypatch):
# Never let the real background poller touch the network during a test.
from eco.status_server import gui as gui_module
monkeypatch.setattr(gui_module._Poller, "start", lambda self: None)
w = StatusServerMonitor("http://fake-host:8091")
yield w
w._poller.stop()
def _health(**overrides):
h = {
"state": "ready", "generation": 3, "uptime_s": 120.0,
"n_initialized": 80, "n_target_names": 86, "n_failed": 6,
"state_seconds": 5.0, "n_direct_read": 16000, "n_monitorable": 10000,
"n_monitored": 0, "rss_mb": 2000.0, "n_threads": 100,
"cpu_seconds": 300.0, "failed_names": [], "failed_required": [],
}
h.update(overrides)
return h
# --------------------------------------------------------------------------
# health rendering
def test_ready_state_is_shown_in_ok_color(win):
win._on_health(_health())
assert "ready" in win.state_label.text()
assert COLOR_OK in win.state_label.styleSheet()
assert win.required_banner.isHidden()
def test_progress_bar_reflects_init_fraction(win):
win._on_health(_health(n_initialized=43, n_target_names=86))
assert win.progress.value() == 50
def test_required_failure_shows_the_red_banner(win):
win._on_health(_health(
state="ready", failed_names=["mon_und", "scilog"],
failed_required=["mon_und", "scilog"],
))
assert not win.required_banner.isHidden()
assert "mon_und" in win.required_banner.text()
assert "scilog" in win.required_banner.text()
assert "REQUIRED" in win.required_banner.text()
# a required failure must not also be double-listed as "other"
assert win.other_failed_label.text() == ""
def test_non_required_failure_does_not_trigger_the_red_banner(win):
win._on_health(_health(failed_names=["xrd"], failed_required=[]))
assert win.required_banner.isHidden()
assert "xrd" in win.other_failed_label.text()
def test_mixed_failures_split_correctly(win):
win._on_health(_health(
failed_names=["mon_und", "xrd"], failed_required=["mon_und"],
))
assert "mon_und" in win.required_banner.text()
assert "xrd" not in win.required_banner.text()
assert "xrd" in win.other_failed_label.text()
assert "mon_und" not in win.other_failed_label.text()
def test_eta_is_shown_while_initializing(win):
win._on_health(_health(
state="initializing", n_initialized=50, n_target_names=100,
state_seconds=10.0,
))
# 50% done in 10s -> another ~10s projected
assert "eta" in win.eta_label.text()
def test_no_eta_once_ready(win):
win._on_health(_health(state="ready"))
assert "eta" not in win.eta_label.text()
def test_buttons_disabled_while_busy(win):
win._on_health(_health(state="initializing"))
assert not win.btn_failed.isEnabled()
win._on_health(_health(state="ready"))
assert win.btn_failed.isEnabled()
def test_unreachable_server_turns_the_dot_red_and_keeps_last_state(win):
win._on_health(_health(state="ready"))
win._on_health_failed("ConnectionError: refused")
assert COLOR_BAD in win.dot.styleSheet()
assert "ready" in win.state_label.text() # stale, but still shown
# --------------------------------------------------------------------------
# stats table
def test_stats_summary_line(win):
win._on_stats({"summary": {"n": 5, "n_errors": 1, "avg_duration_s": 2.5},
"recent": []})
assert "5 operation" in win.summary_label.text()
assert "1 error" in win.summary_label.text()
def test_empty_stats_says_so(win):
win._on_stats({"summary": {"n": 0, "n_errors": 0}, "recent": []})
assert "no requests" in win.summary_label.text()
def test_stats_table_populates_and_colors_errors(win):
win._on_stats({
"summary": {"n": 2, "n_errors": 1},
"recent": [
{"at": 1000.0, "kind": "snapshot", "duration_s": 1.2, "n_entries": 99},
{"at": 1001.0, "kind": "capture", "duration_s": 0.5, "error": "boom"},
],
})
assert win.table.rowCount() == 2
# most recent first
assert win.table.item(0, 1).text() == "capture"
assert win.table.item(0, 4).text() == "boom"
assert win.table.item(0, 4).foreground().color().name() == QtGui.QColor(COLOR_BAD).name()
assert win.table.item(1, 1).text() == "snapshot"
assert win.table.item(1, 4).text() == ""
# --------------------------------------------------------------------------
# reinit action + close guard
def test_reinit_click_disables_buttons_and_calls_the_client(win, qapp):
calls = []
win.client.reinit = lambda **kw: calls.append(kw) or {
"status": "ok", "message": "reinit started in background",
}
win.btn_failed.click()
win._reinit_worker.wait(2000)
qapp.processEvents()
assert calls and calls[0]["mode"] == "failed"
assert calls[0]["wait"] is False, "must not block the GUI thread for the rebuild"
assert "reinit started in background" in win.action_status.text()
def test_reinit_request_does_not_block_the_window_from_closing(win, qapp):
"""The bug this guards against: a reinit used to keep the worker thread
(and so closeEvent's guard) alive for the whole rebuild, which can run
for minutes - making the window impossible to close even though it is
only a monitor and the rebuild itself is entirely server-side."""
win.client.reinit = lambda **kw: {
"status": "ok", "message": "reinit started in background",
}
win.btn_full.click()
win._reinit_worker.wait(2000)
qapp.processEvents()
assert win._reinit_worker is None
ev = QtGui.QCloseEvent()
win.closeEvent(ev)
assert ev.isAccepted()
def test_reinit_error_is_shown(win, qapp):
def boom(**kw):
raise RuntimeError("server unreachable")
win.client.reinit = boom
win.btn_full.click()
win._reinit_worker.wait(2000)
qapp.processEvents()
assert "reinit failed" in win.action_status.text()
assert "server unreachable" in win.action_status.text()
def test_window_refuses_to_close_while_a_reinit_is_running(win, qapp):
import threading
release = threading.Event()
win.client.reinit = lambda **kw: (release.wait(2), {})[1]
win.btn_failed.click()
qapp.processEvents()
assert win._reinit_worker.isRunning()
ev = QtGui.QCloseEvent()
win.closeEvent(ev)
assert not ev.isAccepted()
assert "cannot close" in win.action_status.text()
release.set()
win._reinit_worker.wait(2000)
def test_window_closes_normally_when_idle(win):
ev = QtGui.QCloseEvent()
win.closeEvent(ev)
assert ev.isAccepted()