Files
eco/tests/test_status_server_namespace_component.py
T
lemke_handClaude Sonnet 5 d41fb10a7d StatusServer: richer status(), and recording_mode as a validated choice
status() now shows the failed component names (not just the count), a
red/yellow/green state indicator, and a summary of recent request activity
(count, errors, last kind/age/duration) from /stats - previously only
reachable via separate failures()/stats() calls.

recording_mode is now an _AdjustableFSChoice (enum_strs=("all", "throttle",
"sample"), the exact set RecordingSession.__init__ accepts) instead of a
freeform AdjustableFS: rejects an invalid value at set_target_value time,
and exposes enum_strs so it structurally satisfies
eco.elements.protocols.AdjustableEnum for widget-layer dropdown display.
Deliberately not eco.elements.adjustable.AdjustableEnum itself, which
stores an IntEnum's integer rather than the string - storage stays the
plain string the server already parses, no server-side change needed.

Also documents the max_value_elements/max_points_per_channel distinction
inline (per-value-width filter vs. per-channel-length cap over time) -
not obvious from the settings' names alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:34:22 +02:00

240 lines
8.3 KiB
Python

"""eco.status_server.namespace_component.StatusServer: the namespace-level
handle for a long-running status server (bernina.status_server, alongside
daq/scans) - control, inspection, and the recording settings as real
AdjustableFS children (filesystem-based, so another process reading the
same shared config tree sees a changed setting with no REST call needed).
The underlying StatusServerClient is monkeypatched out entirely - these
tests are about StatusServer's own delegation/settings/gui-launch logic,
not the HTTP client (already covered in test_status_server.py). The
settings' config_dir is pointed at tmp_path, never the real shared
eco_cnf_bernina tree.
"""
import subprocess
import time
import pytest
from eco.status_server.namespace_component import StatusServer
@pytest.fixture
def server(monkeypatch, tmp_path):
calls = {}
class FakeClient:
def __init__(self, base_url):
self.base_url = base_url
def health(self):
calls.setdefault("health", 0)
calls["health"] += 1
return {
"state": "ready", "ready": True, "n_initialized": 80,
"n_target_names": 82, "n_failed": 1, "failed_names": ["bad_component"],
"n_monitorable": 9000, "generation": 3,
"uptime_s": 120.0, "failed_required": [],
}
def failures(self):
return {"x": "boom"}
def stats(self, limit=None, kind=None):
calls["stats"] = {"limit": limit, "kind": kind}
return {
"summary": {
"n": 42, "n_errors": 0, "last_at": time.time() - 3.5,
"last_kind": "snapshot", "last_duration_s": 0.017,
},
"recent": [],
}
def monitor_policy(self):
return {"max_rate_hz": 10.0, "auto_monitor_default": True}
def restart(self, wait=True, timeout=1800, progress=True):
calls["restart"] = {"wait": wait, "timeout": timeout, "progress": progress}
return {"status": "ok"}
def reinit(self, mode="failed", wait=True, **kwargs):
calls["reinit"] = {"mode": mode, "wait": wait, **kwargs}
return {"status": "ok"}
s = StatusServer("http://fake:8091", name="status_server", config_dir=tmp_path)
s._client = FakeClient("http://fake:8091")
s._calls = calls
return s
# --------------------------------------------------------------------------
# settings: real AdjustableFS children (filesystem-based), not yet
# consumed by Daq or the server
def test_recording_settings_have_sensible_defaults(server):
assert server.recording_mode.get_current_value() == "throttle"
assert server.recording_min_interval.get_current_value() == 0.1
assert server.recording_sample_interval.get_current_value() == 0.1
assert server.recording_max_value_elements.get_current_value() is None
assert server.recording_max_points_per_channel.get_current_value() == 100_000
def test_recording_mode_exposes_its_choices(server):
# structural eco.elements.protocols.AdjustableEnum: any Adjustable with
# a truthy enum_strs qualifies, e.g. for widget-layer dropdown display
assert server.recording_mode.enum_strs == ("all", "throttle", "sample")
def test_recording_mode_rejects_an_invalid_choice(server):
with pytest.raises(ValueError):
server.recording_mode.set_target_value("bogus")
# rejected before ever touching the backing file
assert server.recording_mode.get_current_value() == "throttle"
def test_recording_settings_are_settable(server):
# set_target_value(hold=False, the default) starts the write on a
# background thread and returns immediately - wait() before asserting,
# same as any other Changer-backed Adjustable.
server.recording_mode.set_target_value("all").wait()
server.recording_max_value_elements.set_target_value(1024).wait()
assert server.recording_mode.get_current_value() == "all"
assert server.recording_max_value_elements.get_current_value() == 1024
def test_a_setting_changed_in_one_process_is_visible_in_another(tmp_path):
"""The actual point of AdjustableFS over AdjustableMemory: a second
StatusServer instance (standing in for a second session, or the server
process itself, reading the same shared config tree) sees a value
changed by the first with no communication between the two at all -
just the shared filesystem."""
a = StatusServer("http://fake:8091", name="a", config_dir=tmp_path)
b = StatusServer("http://fake:8091", name="b", config_dir=tmp_path)
a.recording_mode.set_target_value("sample").wait()
assert b.recording_mode.get_current_value() == "sample"
b.recording_max_value_elements.set_target_value(2048).wait()
assert a.recording_max_value_elements.get_current_value() == 2048
# --------------------------------------------------------------------------
# control: delegates straight to the client
def test_restart_delegates_to_the_client(server):
result = server.restart(wait=False, timeout=10, progress=False)
assert result == {"status": "ok"}
assert server._calls["restart"] == {"wait": False, "timeout": 10, "progress": False}
def test_reinit_delegates_to_the_client(server):
result = server.reinit(mode="full")
assert result == {"status": "ok"}
assert server._calls["reinit"]["mode"] == "full"
# --------------------------------------------------------------------------
# inspection
def test_status_prints_a_summary_and_returns_health(server, capsys):
h = server.status()
assert h["state"] == "ready"
out = capsys.readouterr().out
assert "ready" in out
assert "80/82" in out
def test_status_lists_failed_component_names(server, capsys):
server.status()
out = capsys.readouterr().out
assert "failed:" in out
assert "bad_component" in out
def test_status_omits_failed_line_when_nothing_failed(server, capsys):
server._client.health = lambda: {
"state": "ready", "ready": True, "n_initialized": 82, "n_target_names": 82,
"n_failed": 0, "failed_names": [], "n_monitorable": 9000, "generation": 3,
"uptime_s": 120.0, "failed_required": [],
}
server.status()
out = capsys.readouterr().out
assert "failed:" not in out
def test_status_shows_recent_request_activity(server, capsys):
server.status()
out = capsys.readouterr().out
assert "requests:" in out
assert "42 recent" in out
assert "snapshot" in out
def test_status_omits_request_line_when_no_stats_yet(server, capsys):
server._client.stats = lambda limit=None, kind=None: {"summary": {"n": 0}, "recent": []}
server.status()
out = capsys.readouterr().out
assert "requests:" not in out
def test_status_warns_in_red_about_failed_required(server, capsys):
server._client.health = lambda: {
"state": "ready", "n_initialized": 80, "n_target_names": 82,
"n_failed": 1, "n_monitorable": 9000, "generation": 3,
"uptime_s": 120.0, "failed_required": ["tt_kb"],
}
server.status()
out = capsys.readouterr().out
assert "tt_kb" in out
assert "REQUIRED" in out
def test_stats_and_failures_and_monitor_policy_delegate(server):
assert server.failures() == {"x": "boom"}
assert server.monitor_policy()["max_rate_hz"] == 10.0
server.stats(limit=5, kind="capture")
assert server._calls["stats"] == {"limit": 5, "kind": "capture"}
def test_repr_shows_state_without_raising(server):
assert "ready" in repr(server)
def test_repr_also_shows_the_recording_settings(server):
r = repr(server)
assert "recording_mode" in r
assert "throttle" in r
assert "recording_max_points_per_channel" in r
def test_repr_degrades_when_unreachable(server):
def boom():
raise ConnectionError("refused")
server._client.health = boom
assert "unreachable" in repr(server)
# --------------------------------------------------------------------------
# gui: a detached subprocess, not an embedded widget
def test_gui_launches_a_detached_subprocess(server, monkeypatch):
calls = []
class FakeProc:
pid = 12345
def fake_popen(cmd, **kwargs):
calls.append(cmd)
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
proc = server.gui()
assert proc.pid == 12345
assert calls[0][-2:] == ["--url", "http://fake:8091"]
assert "eco.status_server.gui" in calls[0]