recspace geom, camera fixes
This commit is contained in:
@@ -305,23 +305,42 @@ class CamserverConfig(Assembly):
|
||||
|
||||
|
||||
def _spawn_separate_process_viewer(
|
||||
pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None
|
||||
pvname,
|
||||
name=None,
|
||||
cam_class=None,
|
||||
pipeline_url=None,
|
||||
rate_hz=10.0,
|
||||
theme=None,
|
||||
dockable=True,
|
||||
):
|
||||
"""Launch eco.widgets.camserver_stream_qt's own CLI as an independent
|
||||
OS process and embed its window into a local wrapper via
|
||||
eco.widgets.subprocess_embed -- the same spawn/WINID-handshake/embed
|
||||
mechanism eco.widgets.camserver_panel_qt already uses per-viewer (see
|
||||
that module and eco.widgets.subprocess_embed.spawn_and_embed), so the
|
||||
resulting window can be docked into the shared EcoDesktopApp workbench
|
||||
(see CameraBasler._default_dock_in) instead of popping untethered.
|
||||
OS process. dockable=True (the default): embed its window into a local
|
||||
wrapper via eco.widgets.subprocess_embed's WINID-handshake/embed
|
||||
mechanism (the same one eco.widgets.camserver_panel_qt uses per-viewer,
|
||||
see that module and eco.widgets.subprocess_embed.spawn_and_embed), so
|
||||
the result can be docked into the shared EcoDesktopApp workbench (see
|
||||
CameraBasler._default_dock_in) instead of popping untethered.
|
||||
|
||||
Why a separate process at all: Qt's event loop is only pumped between
|
||||
IPython prompts (via the terminal's GUI-integration hook, same as
|
||||
eco.utilities.strip_plot's own module docstring explains for exactly
|
||||
this reason). A viewer built in-process therefore freezes -- both its
|
||||
live image updates and the whole window's responsiveness -- for the
|
||||
duration of any single blocking statement in this session (a
|
||||
synchronous motor move, a long scan, ...). A separate-process viewer
|
||||
dockable=False: skip that embedding entirely -- the subprocess's window
|
||||
is a plain, independent, WM-managed top-level window (see
|
||||
eco.widgets.subprocess_embed.DetachedProcessWindow), never dockable
|
||||
(widget(dock_in=True) then silently no-ops, per
|
||||
EcoDesktopApp._dock_widget_object), but immune to embedding's own
|
||||
fragility: QWindow.fromWinId()/createWindowContainer()-based reparenting
|
||||
is unreliable on some display setups -- confirmed live 2026-09-14 under
|
||||
an SSH-X11-forwarded session ultimately backed by XWayland on the
|
||||
client side (content drifting outside its container, not tracking
|
||||
resizes, not receiving clicks) -- see the "Camera viewer XWayland
|
||||
embedding" project memory. Prefer this whenever you don't need docking
|
||||
and have seen that class of glitch.
|
||||
|
||||
Why a separate process at all (either way): Qt's event loop is only
|
||||
pumped between IPython prompts (via the terminal's GUI-integration
|
||||
hook, same as eco.utilities.strip_plot's own module docstring explains
|
||||
for exactly this reason). A viewer built in-process therefore freezes
|
||||
-- both its live image updates and the whole window's responsiveness
|
||||
-- for the duration of any single blocking statement in this session
|
||||
(a synchronous motor move, a long scan, ...). A separate-process viewer
|
||||
has its own independent event loop untouched by that.
|
||||
|
||||
`name`: the eco device's own alias name (e.g. "bernina.cam1", NOT the
|
||||
@@ -344,16 +363,18 @@ def _spawn_separate_process_viewer(
|
||||
use `separate_process=False` (in-process, `cam=self` -- e.g.
|
||||
camera.screenpanel_ana for intensity computation) instead.
|
||||
|
||||
Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow (the
|
||||
.window/.stop() wrapper convention every eco Qt widget follows -- see
|
||||
EcoDesktopApp._dock_widget_object) -- NOT a bare subprocess.Popen like
|
||||
before, since it must be dockable via Assembly.widget()'s dock_in
|
||||
machinery (see CameraBasler/CameraPCO._default_dock_in)."""
|
||||
Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow or (when
|
||||
dockable=False) .DetachedProcessWindow -- both follow the .window/
|
||||
.stop() wrapper convention every eco Qt widget follows (see
|
||||
EcoDesktopApp._dock_widget_object), NOT a bare subprocess.Popen like
|
||||
before this pair existed."""
|
||||
import sys
|
||||
|
||||
from eco.widgets.subprocess_embed import EmbeddedProcessWindow
|
||||
from eco.widgets.subprocess_embed import DetachedProcessWindow, EmbeddedProcessWindow
|
||||
|
||||
argv = [pvname, "--kind", "camera_pipeline", "--embed", "--rate", str(rate_hz)]
|
||||
argv = [pvname, "--kind", "camera_pipeline", "--rate", str(rate_hz)]
|
||||
if dockable:
|
||||
argv += ["--embed"]
|
||||
if pipeline_url:
|
||||
argv += ["--pipeline-url", pipeline_url]
|
||||
if theme:
|
||||
@@ -364,7 +385,9 @@ def _spawn_separate_process_viewer(
|
||||
argv += ["--cam-class", cam_class]
|
||||
|
||||
cmd = [sys.executable, "-m", "eco.widgets.camserver_stream_qt", *argv]
|
||||
return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}")
|
||||
if dockable:
|
||||
return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}")
|
||||
return DetachedProcessWindow(cmd)
|
||||
|
||||
|
||||
def get_camera_calibration(camera):
|
||||
@@ -867,35 +890,45 @@ class CameraBasler(Assembly):
|
||||
theme=None,
|
||||
auto_start=True,
|
||||
separate_process=True,
|
||||
dockable=True,
|
||||
):
|
||||
"""Open the live cam_server "screen panel" viewer for this camera's
|
||||
own default processing pipeline -- the pipeline name is resolved
|
||||
automatically from self.pvname (no need to know or guess a
|
||||
pipeline/instance name), mirroring pshell's own screen panel (see
|
||||
eco.widgets.camserver_stream_qt.resolve_camera_pipeline for the
|
||||
exact "{camera}_sp" naming convention and auto-create-if-missing
|
||||
behavior this reuses). The window's "Camera Settings" button opens
|
||||
widget(normal=True) (the normal property-grid display) -- mirrors
|
||||
eco.devices_general.cameras_ptz.AxisPTZ._widget_viewer().
|
||||
"""Open the live cam_server viewer for this camera's own default
|
||||
processing pipeline (resolved automatically from self.pvname).
|
||||
|
||||
separate_process=True (the default): run the viewer in its own OS
|
||||
process instead of this one -- immune to this session blocking on
|
||||
something (a synchronous motor move, a long scan, ...) -- pops as
|
||||
its own standalone window by default, but can be docked into the
|
||||
shared EcoDesktopApp workbench instead via widget(dock_in=True)
|
||||
(see _default_dock_in/Assembly._maybe_dock), titled by this
|
||||
camera's own eco name, and with its own independently-built camera
|
||||
object (talking directly to EPICS/cam_server) behind its "Camera Settings" button
|
||||
-- see _spawn_separate_process_viewer for the full trade-off and
|
||||
the deferred-IPC TODO. Returns an
|
||||
eco.widgets.subprocess_embed.EmbeddedProcessWindow rather than a
|
||||
CamServerStreamQt instance in that case.
|
||||
separate_process=True (the default): runs in its own OS process, so
|
||||
it keeps updating/responding even while this session is blocked
|
||||
(a motor move, a scan, ...). Costs ~10-15s to start (a fresh process
|
||||
importing eco) and its "Camera Settings" button opens an
|
||||
independent camera object talking directly to EPICS/cam_server --
|
||||
not this one, so e.g. camera.screenpanel_ana state set here isn't
|
||||
reflected there.
|
||||
|
||||
separate_process=False: build the viewer in this process instead,
|
||||
with a direct (not rebuilt) `cam=self` reference -- e.g. for
|
||||
camera.screenpanel_ana intensity/analysis access tied to this
|
||||
exact object, at the cost of the viewer freezing for the duration
|
||||
of any blocking statement in this session."""
|
||||
dockable=True (the default): standalone window unless you pass
|
||||
widget(dock_in=True) to dock it into the shared EcoDesktopApp
|
||||
workbench -- but either way, the window is embedded into its
|
||||
wrapper via X11 reparenting (QWindow.fromWinId +
|
||||
createWindowContainer, see eco.widgets.subprocess_embed), which
|
||||
is known to misbehave on some desktops/remote-display setups
|
||||
(embedded content drifting outside its container, not tracking
|
||||
resizes, or not receiving clicks -- confirmed under an
|
||||
SSH-X11-forwarded session backed by XWayland).
|
||||
|
||||
dockable=False: skips that embedding entirely -- a genuine,
|
||||
independent, WM-managed top-level window that can never be
|
||||
dockable (widget(dock_in=True) then silently no-ops), but immune
|
||||
to the reparenting glitches above. Prefer this if you've hit that
|
||||
class of display bug and don't need docking; separate_process=
|
||||
False (below) is the other way around it, if you don't need a
|
||||
separate process either.
|
||||
|
||||
separate_process=False: builds the viewer in this process instead --
|
||||
starts instantly, no subprocess/X11-embedding involved so no risk
|
||||
of the display glitches above, and `cam=self` is the exact same
|
||||
object (screenpanel_ana etc. all tied to it). Trade-off: the viewer
|
||||
freezes (no repaint, no clicks) for the duration of any blocking
|
||||
statement in this session (a synchronous motor move, a long scan,
|
||||
...) -- prefer this whenever you're just looking at the image and
|
||||
not about to run something long alongside it."""
|
||||
alias = getattr(self, "alias", None)
|
||||
name = alias.get_full_name() if alias is not None else None
|
||||
|
||||
@@ -907,6 +940,7 @@ class CameraBasler(Assembly):
|
||||
pipeline_url=pipeline_url,
|
||||
rate_hz=rate_hz,
|
||||
theme=theme,
|
||||
dockable=dockable,
|
||||
)
|
||||
|
||||
from ..widgets.camserver_stream_qt import make_camserver_stream_qt
|
||||
@@ -1138,10 +1172,11 @@ class CameraPCO(Assembly):
|
||||
theme=None,
|
||||
auto_start=True,
|
||||
separate_process=True,
|
||||
dockable=True,
|
||||
):
|
||||
"""Open the live cam_server "screen panel" viewer for this camera's
|
||||
own default processing pipeline -- see CameraBasler._widget_viewer(),
|
||||
which this mirrors (separate_process included)."""
|
||||
which this mirrors (separate_process/dockable included)."""
|
||||
alias = getattr(self, "alias", None)
|
||||
name = alias.get_full_name() if alias is not None else None
|
||||
|
||||
@@ -1153,6 +1188,7 @@ class CameraPCO(Assembly):
|
||||
pipeline_url=pipeline_url,
|
||||
rate_hz=rate_hz,
|
||||
theme=theme,
|
||||
dockable=dockable,
|
||||
)
|
||||
|
||||
from ..widgets.camserver_stream_qt import make_camserver_stream_qt
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,11 @@ import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import html as html_lib
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
@@ -27,6 +32,72 @@ from epics import PV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Vendored from swissfel/bernina-scripts apps/dev/diffractometer_3d.html: a
|
||||
# self-contained Three.js reciprocal-space viewer that needs no server, only
|
||||
# a JSON payload (angles, UB matrix, k-magnitude, surface normal) written
|
||||
# into a hidden element it polls every 150ms.
|
||||
_GEOM_WIDGET_HTML_PATH = Path(__file__).parent / "diffractometer_3d.html"
|
||||
|
||||
|
||||
def _build_geom_widget_html(entries):
|
||||
"""Wrap the vendored 3D viewer with a sidebar that lets you click through
|
||||
``entries`` (each ``{"label": str, "payload": dict}``), writing the
|
||||
clicked entry's payload into the "#diff3d-payload" element the viewer
|
||||
polls."""
|
||||
scene_html = _GEOM_WIDGET_HTML_PATH.read_text()
|
||||
srcdoc = html_lib.escape(scene_html)
|
||||
entries_json = json.dumps(entries)
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Reciprocal space geometry</title>
|
||||
<style>
|
||||
html, body {{ margin: 0; height: 100%; font-family: sans-serif; }}
|
||||
#layout {{ display: flex; height: 100vh; }}
|
||||
#sidebar {{ width: 240px; overflow: auto; border-right: 1px solid #ccc; padding: 8px; box-sizing: border-box; }}
|
||||
#sidebar h3 {{ margin: 4px 0 8px; font-size: 13px; }}
|
||||
button.refl {{
|
||||
display: block; width: 100%; text-align: left; margin: 2px 0;
|
||||
padding: 5px 6px; font-size: 12px; border: 1px solid #ccc; background: #fff;
|
||||
cursor: pointer;
|
||||
}}
|
||||
button.refl.active {{ background: #cfe8fb; border-color: #7ab8e6; }}
|
||||
#frame-wrap {{ flex: 1; min-width: 0; }}
|
||||
iframe {{ width: 100%; height: 100%; border: 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="layout">
|
||||
<div id="sidebar">
|
||||
<h3>Reflections</h3>
|
||||
<div id="refl-list"></div>
|
||||
</div>
|
||||
<div id="frame-wrap">
|
||||
<iframe id="diff3d" srcdoc="{srcdoc}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<span id="diff3d-payload" style="display:none"></span>
|
||||
<script>
|
||||
const ENTRIES = {entries_json};
|
||||
function show(i) {{
|
||||
document.getElementById('diff3d-payload').textContent = JSON.stringify(ENTRIES[i].payload);
|
||||
document.querySelectorAll('#refl-list .refl').forEach((b, j) => b.classList.toggle('active', j === i));
|
||||
}}
|
||||
const list = document.getElementById('refl-list');
|
||||
ENTRIES.forEach((entry, i) => {{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'refl';
|
||||
b.textContent = entry.label;
|
||||
b.onclick = () => show(i);
|
||||
list.appendChild(b);
|
||||
}});
|
||||
if (ENTRIES.length) show(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Diffractometer_Dummy(Assembly):
|
||||
def __init__(self, *args, name=None, **kwargs):
|
||||
@@ -1024,6 +1095,73 @@ class DiffGeometryYou(Assembly):
|
||||
)
|
||||
return solution
|
||||
|
||||
def _geom_entry(self, label, mu, delta, gamma, eta, chi, phi, energy=None, hkl=None):
|
||||
"""Build one clickable entry (angles + UB + surface normal, as the
|
||||
vendored 3D viewer expects) for the given diffractometer position."""
|
||||
if energy is None:
|
||||
energy = self.get_energy()
|
||||
if hkl is None:
|
||||
hkl = self.calc_hkl(
|
||||
mu=mu, delta=delta, gamma=gamma, eta=eta, chi=chi, phi=phi, energy=energy
|
||||
)
|
||||
if hkl is not None:
|
||||
label = f"{label} ({hkl[0]:.3g}, {hkl[1]:.3g}, {hkl[2]:.3g})"
|
||||
lam = self.en2lam(energy)
|
||||
n_phi = np.asarray(self.ubcalc.n_phi, dtype=float).ravel()
|
||||
norm = np.linalg.norm(n_phi)
|
||||
return {
|
||||
"label": label,
|
||||
"payload": {
|
||||
"angles": {
|
||||
"mu": mu,
|
||||
"eta": eta,
|
||||
"chi": chi,
|
||||
"phi": phi,
|
||||
"delta": delta,
|
||||
"gamma": gamma,
|
||||
},
|
||||
"kMag": 2 * np.pi / lam,
|
||||
"ub": np.asarray(self.ubcalc.UB, dtype=float).tolist(),
|
||||
"nPhi": (n_phi / norm).tolist() if norm > 0 else [0.0, 0.0, 1.0],
|
||||
},
|
||||
}
|
||||
|
||||
def widget_geom(self):
|
||||
"""Open a browser tab visualizing this crystal's reciprocal-space
|
||||
geometry: the current diffractometer position plus every stored
|
||||
reference reflection, clickable in a sidebar.
|
||||
|
||||
Reuses the 3D viewer from swissfel/bernina-scripts
|
||||
apps/dev/diffractometer_3d.html unmodified - only the UB matrix,
|
||||
current/stored angles and energy are fed in, so none of that
|
||||
notebook's manual unit-cell/orientation input fields are needed here;
|
||||
this crystal's own UB calculation already has them.
|
||||
"""
|
||||
self.to_diffcalc()
|
||||
mu, delta, gamma, eta, chi, phi = self.get_diffractometer_angles()
|
||||
entries = [self._geom_entry("current", mu, delta, gamma, eta, chi, phi)]
|
||||
for i, refl in enumerate(self.reflections()):
|
||||
mu, delta, gamma, eta, chi, phi = refl["position"]
|
||||
tag = refl.get("tag") or f"#{i}"
|
||||
entries.append(
|
||||
self._geom_entry(
|
||||
str(tag),
|
||||
mu,
|
||||
delta,
|
||||
gamma,
|
||||
eta,
|
||||
chi,
|
||||
phi,
|
||||
energy=refl["energy"],
|
||||
hkl=refl["hkl"],
|
||||
)
|
||||
)
|
||||
path = Path(tempfile.gettempdir()) / f"eco_widget_geom_{self.name}.html"
|
||||
path.write_text(_build_geom_widget_html(entries))
|
||||
webbrowser.open(f"file://{path}")
|
||||
print(f"Opened reciprocal-space geometry view: {path}")
|
||||
return path
|
||||
|
||||
def calc_angles_unique(self, h=None, k=None, l=None, energy=None):
|
||||
"""calculate unique solution of diffractometer angles for a given h,k,l and energy in eV.
|
||||
If any of the h, k, l are not given, their current value is used instead.
|
||||
|
||||
@@ -178,6 +178,63 @@ def spawn_and_embed(target, cmd, env=None, timeout_s=EMBED_TIMEOUT_S, parent=Non
|
||||
return process
|
||||
|
||||
|
||||
class DetachedProcessWindow:
|
||||
""".window/.stop()-shaped wrapper (same convention as
|
||||
EmbeddedProcessWindow) around a viewer subprocess that is deliberately
|
||||
NOT embedded anywhere -- its window is a plain, independent, fully
|
||||
WM-managed top-level window, exactly as if the CLI had been run by
|
||||
hand. `.window` is always None: there is nothing local to reparent or
|
||||
dock, which is the point.
|
||||
|
||||
Exists to route around QWindow.fromWinId()/createWindowContainer()-
|
||||
based embedding (see spawn_and_embed/EmbeddedProcessWindow) being
|
||||
unreliable on some display setups -- XWayland in particular (a Wayland
|
||||
compositor's X11-compat layer only partially emulates the
|
||||
XReparentWindow-based trick that embedding depends on), confirmed live
|
||||
2026-09-14: embedded content drifting outside its container, not
|
||||
tracking resizes, or not receiving clicks at all. A plain top-level
|
||||
window never gets reparented in the first place, so it's immune to
|
||||
that whole class of bug -- at the cost of never being dockable into an
|
||||
EcoDesktopApp workbench (EcoDesktopApp._dock_widget_object checks for
|
||||
a real `.window` QWidget and is a documented no-op otherwise, so
|
||||
`widget(dock_in=True)` on one of these silently just leaves it
|
||||
standalone rather than erroring)."""
|
||||
|
||||
window = None
|
||||
|
||||
def __init__(self, cmd, env=None):
|
||||
# QProcess's signals (on_stderr below) are delivered through Qt's
|
||||
# event loop like any other -- needs a QApplication to exist, same
|
||||
# requirement as EmbeddedProcessWindow, even though this class
|
||||
# never builds a QWidget of its own.
|
||||
global _app_ref
|
||||
if QtWidgets.QApplication.instance() is None:
|
||||
_app_ref = QtWidgets.QApplication([])
|
||||
|
||||
self.process = QtCore.QProcess()
|
||||
self.process.setProcessChannelMode(QtCore.QProcess.SeparateChannels)
|
||||
|
||||
def on_stderr():
|
||||
# Same surfacing as spawn_and_embed's on_stderr -- a crash
|
||||
# before/without ever showing anything would otherwise be a
|
||||
# silent disappearance.
|
||||
chunk = bytes(self.process.readAllStandardError()).decode(errors="ignore")
|
||||
if chunk:
|
||||
sys.stderr.write(f"[subprocess_embed] viewer stderr: {chunk}")
|
||||
sys.stderr.flush()
|
||||
|
||||
self.process.readyReadStandardError.connect(on_stderr)
|
||||
self.process.setProcessEnvironment(env if env is not None else child_process_environment())
|
||||
self.process.start(cmd[0], cmd[1:])
|
||||
|
||||
def stop(self):
|
||||
if self.process is not None and self.process.state() != QtCore.QProcess.NotRunning:
|
||||
self.process.terminate()
|
||||
if not self.process.waitForFinished(2000):
|
||||
self.process.kill()
|
||||
self.process.waitForFinished(2000)
|
||||
|
||||
|
||||
class EmbeddedProcessWindow:
|
||||
""".window/.stop()-shaped wrapper (eco's standard Qt-widget-wrapper
|
||||
convention -- DisplayQt/AxisPTZStreamQt/CamServerStreamQt/...) around
|
||||
|
||||
@@ -148,13 +148,17 @@ def test_camera_basler_separate_process_is_the_default(monkeypatch):
|
||||
def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def fake_spawn(pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None):
|
||||
def fake_spawn(
|
||||
pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None,
|
||||
dockable=True,
|
||||
):
|
||||
calls["pvname"] = pvname
|
||||
calls["name"] = name
|
||||
calls["cam_class"] = cam_class
|
||||
calls["pipeline_url"] = pipeline_url
|
||||
calls["rate_hz"] = rate_hz
|
||||
calls["theme"] = theme
|
||||
calls["dockable"] = dockable
|
||||
return "the subprocess handle"
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -176,10 +180,28 @@ def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeyp
|
||||
# and_cam_class below for the real-object case)
|
||||
assert calls == {
|
||||
"pvname": BASLER_PVNAME, "name": None, "cam_class": None,
|
||||
"pipeline_url": None, "rate_hz": 25.0, "theme": "dark",
|
||||
"pipeline_url": None, "rate_hz": 25.0, "theme": "dark", "dockable": True,
|
||||
}
|
||||
|
||||
|
||||
def test_camera_basler_viewer_dockable_false_is_passed_through(monkeypatch):
|
||||
"""widget(dockable=False) must reach _spawn_separate_process_viewer so
|
||||
it can skip the X11-embedding/dock machinery entirely (see
|
||||
eco.widgets.subprocess_embed.DetachedProcessWindow) -- not silently get
|
||||
dropped."""
|
||||
calls = {}
|
||||
monkeypatch.setattr(
|
||||
"eco.devices_general.cameras_swissfel._spawn_separate_process_viewer",
|
||||
lambda pvname, **kwargs: calls.update(pvname=pvname, **kwargs) or "the subprocess handle",
|
||||
)
|
||||
|
||||
fake_self = _FakeCameraSelf(BASLER_PVNAME)
|
||||
result = CameraBasler._widget_viewer(fake_self, dockable=False)
|
||||
|
||||
assert result == "the subprocess handle"
|
||||
assert calls["dockable"] is False
|
||||
|
||||
|
||||
def test_camera_basler_viewer_separate_process_passes_name_and_cam_class(monkeypatch):
|
||||
calls = {}
|
||||
monkeypatch.setattr(
|
||||
@@ -263,6 +285,33 @@ def test_spawn_separate_process_viewer_builds_the_expected_command_line(monkeypa
|
||||
assert captured["title"] == "cam_server stream - bernina.cam1"
|
||||
|
||||
|
||||
def test_spawn_separate_process_viewer_dockable_false_skips_embed_flag(monkeypatch):
|
||||
"""dockable=False must route through DetachedProcessWindow instead of
|
||||
EmbeddedProcessWindow, and must NOT pass --embed -- the whole point is
|
||||
the subprocess never becomes a reparenting target (see
|
||||
eco.widgets.subprocess_embed.DetachedProcessWindow's docstring)."""
|
||||
from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeDetachedProcessWindow:
|
||||
def __init__(self, cmd):
|
||||
captured["cmd"] = cmd
|
||||
|
||||
def fail_if_embedded(*a, **k):
|
||||
raise AssertionError("dockable=False must not build an EmbeddedProcessWindow")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"eco.widgets.subprocess_embed.DetachedProcessWindow", _FakeDetachedProcessWindow
|
||||
)
|
||||
monkeypatch.setattr("eco.widgets.subprocess_embed.EmbeddedProcessWindow", fail_if_embedded)
|
||||
|
||||
result = _spawn_separate_process_viewer(BASLER_PVNAME, dockable=False)
|
||||
|
||||
assert isinstance(result, _FakeDetachedProcessWindow)
|
||||
assert "--embed" not in captured["cmd"]
|
||||
|
||||
|
||||
def test_spawn_separate_process_viewer_omits_eco_name_and_cam_class_when_not_given(monkeypatch):
|
||||
from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer
|
||||
|
||||
|
||||
Reference in New Issue
Block a user