_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>
217 lines
7.0 KiB
Python
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()
|