Files
eco/tests/test_camserver_panel_qt.py
T
2026-08-21 15:10:25 +02:00

192 lines
6.8 KiB
Python

import time
import pytest
pytest.importorskip("qtpy")
from qtpy import QtWidgets
import os
import eco
from eco.widgets.camserver_panel_qt import (
CamServerPanelQt,
_build_viewer_args,
_child_process_environment,
sorted_names,
)
def test_child_process_environment_prepends_the_actual_eco_root():
env = _child_process_environment()
eco_root = os.path.dirname(os.path.dirname(os.path.abspath(eco.__file__)))
pythonpath = env.value("PYTHONPATH", "")
assert pythonpath.split(os.pathsep)[0] == eco_root
def test_child_process_environment_preserves_existing_pythonpath():
os.environ["PYTHONPATH"] = "/some/other/path"
try:
env = _child_process_environment()
finally:
del os.environ["PYTHONPATH"]
pythonpath = env.value("PYTHONPATH", "").split(os.pathsep)
assert "/some/other/path" in pythonpath
def test_child_process_environment_inherits_other_variables():
os.environ["ECO_PANEL_TEST_MARKER"] = "present"
try:
env = _child_process_environment()
finally:
del os.environ["ECO_PANEL_TEST_MARKER"]
assert env.value("ECO_PANEL_TEST_MARKER", "") == "present"
def test_sorted_names_bernina_prefixes_first():
names = ["SATOP31-PSCA176", "SARES20-CAMS142-C3", "alain", "SLAAR21-PSEN135", "SAROP11-PPRM078"]
result = sorted_names(names, prefixes=("SARES", "SAROP", "SLAAR"))
assert result == [
"SARES20-CAMS142-C3",
"SAROP11-PPRM078",
"SLAAR21-PSEN135",
"SATOP31-PSCA176",
"alain",
]
def test_sorted_names_no_prefix_match_is_plain_alpha_sort():
assert sorted_names(["b", "a"], prefixes=()) == ["a", "b"]
def test_build_viewer_args_pipeline_minimal():
assert _build_viewer_args("my_pipeline", "pipeline") == [
"my_pipeline",
"--kind",
"pipeline",
"--embed",
]
def test_build_viewer_args_demo_color_flag():
args = _build_viewer_args("demo", "demo", demo_color=True)
assert "--demo-color" in args
def test_build_viewer_args_non_demo_ignores_demo_color():
args = _build_viewer_args("my_cam", "camera", demo_color=True)
assert "--demo-color" not in args
def test_build_viewer_args_urls_and_rate():
args = _build_viewer_args("cam", "camera", camera_url="http://x:1", rate=5.0)
assert "--camera-url" in args and "http://x:1" in args
assert "--rate" in args and "5.0" in args
def _pump(app, predicate, timeout=45.0, interval=0.05):
"""Process Qt events until `predicate()` is true or `timeout` elapses.
Subprocess-spawn tests need a generous timeout: launching
`python -m eco.widgets.camserver_stream_qt` pays eco's known ~10s
first-import cost (see feedback_no_full_eco_import) before the demo
viewer even starts producing frames."""
t0 = time.time()
while time.time() - t0 < timeout:
app.processEvents()
if predicate():
return True
time.sleep(interval)
return False
def test_panel_spawns_and_isolates_demo_viewer_subprocesses():
"""The core architectural guarantee: each viewer is a separate OS
process, so killing one can't affect another or the panel itself."""
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
panel = CamServerPanelQt(auto_start=False)
panel._build_window()
try:
p1 = panel.add_viewer("demo", "demo-1")
p2 = panel.add_viewer("demo", "demo-2")
started = _pump(
app,
lambda: p1.state() == p1.Running and p2.state() == p2.Running,
)
assert started, "both demo viewer subprocesses should have started"
assert p1.processId() != p2.processId()
resolved = _pump(
app,
lambda: panel._docks[0].embedded and panel._docks[1].embedded,
)
assert resolved, "both viewer docks should have embedded or fallen back within the timeout"
assert len(panel._docks) == 2
# simulate a crash in one viewer and confirm the other, and the
# panel itself, are unaffected
p1.kill()
_pump(app, lambda: p1.state() == p1.NotRunning, timeout=10.0)
_pump(app, lambda: True, timeout=1.0) # let the finished-handler react
assert p2.state() == p2.Running
assert panel.window is not None
# p1's dock was never *truly* embedded here -- this offscreen Qt
# platform can't do real X11 embedding (see the "platform plugin
# does not support foreign windows" warning), so killing it
# surfaces an error in its dock rather than silently removing it
# (see CamServerPanelQt._spawn_viewer.on_finished); p2 is
# completely unaffected either way
assert len(panel._docks) == 2
assert panel._docks[0].process is p1
assert panel._docks[1].process is p2
finally:
panel.stop()
_pump(app, lambda: True, timeout=2.0)
def test_panel_close_button_terminates_only_that_viewer():
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
panel = CamServerPanelQt(auto_start=False)
panel._build_window()
try:
p1 = panel.add_viewer("demo", "demo-a")
p2 = panel.add_viewer("demo", "demo-b")
_pump(app, lambda: p1.state() == p1.Running and p2.state() == p2.Running)
dock_a = panel._docks[0]
panel._remove_dock(dock_a)
_pump(app, lambda: p1.state() == p1.NotRunning, timeout=10.0)
assert len(panel._docks) == 1
assert panel._docks[0].process is p2
assert p2.state() == p2.Running
finally:
panel.stop()
_pump(app, lambda: True, timeout=2.0)
def test_panel_shows_error_dock_when_viewer_process_exits_before_starting():
"""Reproduces the reported bug: a viewer subprocess that dies before
ever printing a WINID (here, forced via a bad --rate value that makes
argparse exit(2) right after eco's own slow import, well before any
window is built) used to have its dock silently removed -- indistinguishable
from a flash-and-vanish with no visible reason. It should now stay,
showing the captured stderr instead."""
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
panel = CamServerPanelQt(auto_start=False)
panel._build_window()
try:
bad_argv = ["demo", "--kind", "demo", "--embed", "--rate", "not-a-number"]
process = panel._spawn_viewer("broken", bad_argv)
finished = _pump(app, lambda: process.state() == process.NotRunning, timeout=40.0)
assert finished, "the broken viewer subprocess should have exited on its own"
resolved = _pump(app, lambda: len(panel._docks) == 1 and panel._docks[0].embedded, timeout=5.0)
assert resolved
assert len(panel._docks) == 1, "the dock should stay (showing the error), not vanish"
finally:
panel.stop()
_pump(app, lambda: True, timeout=2.0)