378 lines
14 KiB
Python
378 lines
14 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}
|
|
calls["stats_n"] = calls.get("stats_n", 0) + 1
|
|
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 start_recording(self, **kwargs):
|
|
calls.setdefault("start_recording", []).append(kwargs)
|
|
return {"n_channels_attached": 100, "n_channels_requested": 120}
|
|
|
|
def capture_recording(self, recording_id, pgroup, run_number, **kwargs):
|
|
calls.setdefault("capture_recording", []).append(
|
|
{"recording_id": recording_id, "pgroup": pgroup,
|
|
"run_number": run_number, **kwargs}
|
|
)
|
|
return {"job_id": "j3", "path": "/data/p1/run0042/aux/monitors.esc.h5"}
|
|
|
|
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]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# monitoring: start_monitoring/stop_monitoring - the recording_id/filename
|
|
# convention centralized here instead of duplicated in Daq (see
|
|
# eco.acquisition.daq_client.Daq.status_server/start_scan_monitoring)
|
|
|
|
|
|
def test_start_monitoring_builds_the_run_recording_id(server):
|
|
recording_id, result = server.start_monitoring("p12345", 42)
|
|
assert recording_id == "p12345_run0042"
|
|
assert result["n_channels_attached"] == 100
|
|
assert server._calls["start_recording"] == [
|
|
{"recording_id": "p12345_run0042", "names": None, "mode": "throttle",
|
|
"min_interval": 0.1, "pgroup": "p12345", "run_number": 42}
|
|
]
|
|
|
|
|
|
def test_start_monitoring_accepts_names_and_mode(server):
|
|
server.start_monitoring("p12345", 7, names=["a", "b"], mode="all",
|
|
min_interval=0.0)
|
|
call = server._calls["start_recording"][0]
|
|
assert call["names"] == ["a", "b"]
|
|
assert call["mode"] == "all"
|
|
assert call["recording_id"] == "p12345_run0007"
|
|
|
|
|
|
def test_stop_monitoring_uses_the_default_filename(server):
|
|
job = server.stop_monitoring("p12345_run0042", "p12345", 42)
|
|
assert job["job_id"] == "j3"
|
|
assert server._calls["capture_recording"] == [
|
|
{"recording_id": "p12345_run0042", "pgroup": "p12345",
|
|
"run_number": 42, "upload": True, "filename": "namespace_monitor.ixp.h5"}
|
|
]
|
|
|
|
|
|
def test_stop_monitoring_can_skip_the_upload(server):
|
|
server.stop_monitoring("p12345_run0042", "p12345", 42, upload=False)
|
|
assert server._calls["capture_recording"][0]["upload"] is False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# diagnostics: plain Detector children (get_current_value() only), backed by
|
|
# health()/stats() data that already exists server-side - see
|
|
# eco.status_server.namespace_store.NamespaceMonitorStore.connection_report
|
|
# and eco.status_server.query_stats.RequestStats.summary
|
|
|
|
|
|
def test_n_initialized_and_n_failed_and_failed_names_are_detectors(server):
|
|
assert server.n_initialized.get_current_value() == 80
|
|
assert server.n_failed.get_current_value() == 1
|
|
assert server.failed_names.get_current_value() == ["bad_component"]
|
|
|
|
|
|
def test_last_namespace_update_reads_last_init_finished(server):
|
|
finished = time.time() - 30
|
|
server._client.health = lambda: {
|
|
"state": "ready", "ready": True, "n_initialized": 80, "n_target_names": 82,
|
|
"n_failed": 0, "failed_names": [], "n_monitorable": 9000, "generation": 3,
|
|
"uptime_s": 120.0, "failed_required": [], "last_init_finished": finished,
|
|
}
|
|
from datetime import datetime
|
|
|
|
assert (
|
|
server.last_namespace_update.get_current_value()
|
|
== datetime.fromtimestamp(finished).isoformat()
|
|
)
|
|
|
|
|
|
def test_last_namespace_update_is_none_when_never_built(server):
|
|
server._client.health = lambda: {
|
|
"state": "initializing", "ready": False, "n_initialized": 0,
|
|
"n_target_names": 82, "n_failed": 0, "failed_names": [],
|
|
"n_monitorable": 0, "generation": 0, "uptime_s": 1.0,
|
|
"failed_required": [], "last_init_finished": None,
|
|
}
|
|
assert server.last_namespace_update.get_current_value() is None
|
|
|
|
|
|
def test_last_request_reports_kind_and_duration(server):
|
|
value = server.last_request.get_current_value()
|
|
assert value["kind"] == "snapshot"
|
|
assert value["duration_s"] == 0.017
|
|
assert value["at"] is not None
|
|
|
|
|
|
def test_last_request_is_none_when_nothing_served_yet(server):
|
|
server._client.stats = lambda limit=None, kind=None: {
|
|
"summary": {"n": 0}, "recent": [],
|
|
}
|
|
assert server.last_request.get_current_value() is None
|
|
|
|
|
|
def test_diagnostics_are_cached_across_reads_within_the_ttl(server, monkeypatch):
|
|
monkeypatch.setattr(time, "time", lambda: 1000.0)
|
|
server.n_initialized.get_current_value()
|
|
server.n_failed.get_current_value()
|
|
server.failed_names.get_current_value()
|
|
# three status-child reads, one shared health() call
|
|
assert server._calls["health"] == 1
|
|
|
|
server.last_request.get_current_value()
|
|
server.last_request.get_current_value()
|
|
# two reads of the same stats-backed detector, one shared stats() call
|
|
assert server._calls["stats_n"] == 1
|
|
|
|
|
|
def test_diagnostics_refetch_after_the_ttl_expires(server, monkeypatch):
|
|
t = [1000.0]
|
|
monkeypatch.setattr(time, "time", lambda: t[0])
|
|
server.n_initialized.get_current_value()
|
|
t[0] += 2.0 # past the 1.0 s cache TTL
|
|
server.n_initialized.get_current_value()
|
|
assert server._calls["health"] == 2
|
|
|
|
|
|
def test_diagnostics_degrade_to_none_when_the_server_is_unreachable(server):
|
|
def boom():
|
|
raise ConnectionError("refused")
|
|
|
|
server._client.health = boom
|
|
server._client.stats = lambda limit=None, kind=None: (_ for _ in ()).throw(
|
|
ConnectionError("refused")
|
|
)
|
|
assert server.n_initialized.get_current_value() is None
|
|
assert server.last_request.get_current_value() is None
|