Skip to content

Commit be27cc4

Browse files
committed
Make camera dialog tests deterministic
Stabilize GUI tests by making backend discovery and capabilities deterministic. Add _select_backend_for_active_cam helper to ensure combo/backend coherence during tests. Replace global fake/opencv mixing with a patch_detect_cameras fixture (staticmethod) and use a stable 'fake' backend in E2E fixtures. Inline CountingBackend in relevant tests and monkeypatch CameraFactory.create as staticmethod to avoid hardware access. In unit tests, patch backend_capabilities for predictable enable/disable state and switch assertions to FPS (supported) instead of gain. Misc: minor test cleanups, docstring tweaks, and staticmethod adjustments for slow scan/loader helpers.
1 parent 6db3f74 commit be27cc4

File tree

2 files changed

+174
-87
lines changed

2 files changed

+174
-87
lines changed

tests/gui/camera_config/test_cam_dialog_e2e.py

Lines changed: 143 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -14,76 +14,90 @@
1414
from dlclivegui.config import CameraSettings, MultiCameraSettings
1515
from dlclivegui.gui.camera_config_dialog import CameraConfigDialog, CameraLoadWorker
1616

17-
# ---------------- Fake backends ----------------
17+
# ---------------------------------------------------------------------
18+
# Helpers
19+
# ---------------------------------------------------------------------
1820

1921

20-
class FakeBackend(CameraBackend):
21-
"""Simple preview backend that always returns an RGB frame."""
22-
23-
def __init__(self, settings):
24-
super().__init__(settings)
25-
self._opened = False
26-
27-
def open(self):
28-
self._opened = True
29-
30-
def close(self):
31-
self._opened = False
32-
33-
def read(self):
34-
return np.zeros((30, 40, 3), dtype=np.uint8), 0.1
35-
36-
37-
class CountingBackend(CameraBackend):
38-
"""Backend that counts opens (used to validate restart behavior)."""
39-
40-
opens = 0
41-
42-
def __init__(self, settings):
43-
super().__init__(settings)
44-
self._opened = False
45-
46-
def open(self):
47-
type(self).opens += 1
48-
self._opened = True
22+
def _select_backend_for_active_cam(dialog: CameraConfigDialog, cam_row: int = 0) -> str:
23+
"""
24+
Ensure backend combo is set to the backend of the active camera at cam_row.
25+
If that backend is not present in the combo, fall back to the current combo backend
26+
and update the camera setting backend to match (so identity/dup logic stays coherent).
27+
Returns the backend key actually selected (lowercase).
28+
"""
29+
# backend requested by the camera settings
30+
backend = (dialog._working_settings.cameras[cam_row].backend or "").lower()
31+
32+
idx = dialog.backend_combo.findData(backend)
33+
if idx >= 0:
34+
dialog.backend_combo.setCurrentIndex(idx)
35+
return backend
36+
37+
# Fallback: use current combo backend (or first item) and update the camera backend to match
38+
fallback = dialog.backend_combo.currentData()
39+
if not fallback and dialog.backend_combo.count() > 0:
40+
fallback = dialog.backend_combo.itemData(0)
41+
dialog.backend_combo.setCurrentIndex(0)
42+
43+
fallback = (fallback or "").lower()
44+
assert fallback, "No backend available in combo"
45+
46+
# Ensure camera backend matches combo so duplicate logic compares apples-to-apples
47+
dialog._working_settings.cameras[cam_row].backend = fallback
48+
# Also update the list item UserRole object (so UI selection holds the updated backend)
49+
try:
50+
item = dialog.active_cameras_list.item(cam_row)
51+
if item is not None:
52+
cam = item.data(Qt.ItemDataRole.UserRole)
53+
if cam is not None:
54+
cam.backend = fallback
55+
item.setData(Qt.ItemDataRole.UserRole, cam)
56+
except Exception:
57+
pass
4958

50-
def close(self):
51-
self._opened = False
59+
# Update labels/UI for consistency
60+
try:
61+
dialog._update_active_list_item(cam_row, dialog._working_settings.cameras[cam_row])
62+
dialog._update_controls_for_backend(fallback)
63+
except Exception:
64+
pass
5265

53-
def read(self):
54-
return np.zeros((30, 40, 3), dtype=np.uint8), 0.1
66+
return fallback
5567

5668

57-
# ---------------- Fixtures ----------------
69+
# ---------------------------------------------------------------------
70+
# Fixtures
71+
# ---------------------------------------------------------------------
5872

5973

6074
@pytest.fixture
61-
def patch_factory(monkeypatch):
75+
def patch_detect_cameras(monkeypatch):
6276
"""
63-
Patch camera factory so no hardware access occurs, and scan is deterministic.
64-
Default backend is FakeBackend unless overridden per-test.
77+
Make discovery deterministic for these tests.
78+
(GUI conftest patches create(), but not necessarily detect_cameras().)
6579
"""
66-
monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s))
67-
6880
monkeypatch.setattr(
6981
CameraFactory,
7082
"detect_cameras",
71-
lambda backend, max_devices=10, **kw: [
72-
DetectedCamera(index=0, label=f"{backend}-X"),
73-
DetectedCamera(index=1, label=f"{backend}-Y"),
74-
],
83+
staticmethod(
84+
lambda backend, max_devices=10, **kw: [
85+
DetectedCamera(index=0, label=f"{backend}-X"),
86+
DetectedCamera(index=1, label=f"{backend}-Y"),
87+
]
88+
),
7589
)
7690

7791

7892
@pytest.fixture
79-
def dialog(qtbot, patch_factory):
93+
def dialog(qtbot, patch_detect_cameras):
8094
"""
81-
E2E fixture: allow scan thread + preview loader + timer to run.
82-
Includes robust teardown to avoid leaked threads/timers.
95+
E2E fixture: dialog with scan worker + loader + preview timer enabled.
96+
Uses a backend that is guaranteed to exist in test registry: 'fake'.
8397
"""
8498
s = MultiCameraSettings(
8599
cameras=[
86-
CameraSettings(name="A", backend="opencv", index=0, enabled=True),
100+
CameraSettings(name="A", backend="fake", index=0, enabled=True),
87101
]
88102
)
89103
d = CameraConfigDialog(None, s)
@@ -109,7 +123,9 @@ def dialog(qtbot, patch_factory):
109123
qtbot.waitUntil(lambda: not getattr(d, "_preview_active", False), timeout=2000)
110124

111125

112-
# ---------------- E2E tests ----------------
126+
# ---------------------------------------------------------------------
127+
# Tests
128+
# ---------------------------------------------------------------------
113129

114130

115131
@pytest.mark.gui
@@ -141,10 +157,28 @@ def test_e2e_preview_start_stop(dialog, qtbot):
141157
def test_e2e_apply_settings_restarts_preview_on_restart_fields(dialog, qtbot, monkeypatch):
142158
"""
143159
Change a restart-relevant field (fps) and verify preview actually restarts
144-
(open() called again) while staying active.
160+
by observing open() being called again.
145161
"""
162+
163+
class CountingBackend(CameraBackend):
164+
opens = 0
165+
166+
def __init__(self, settings):
167+
super().__init__(settings)
168+
self._opened = False
169+
170+
def open(self):
171+
type(self).opens += 1
172+
self._opened = True
173+
174+
def close(self):
175+
self._opened = False
176+
177+
def read(self):
178+
return np.zeros((30, 40, 3), dtype=np.uint8), 0.1
179+
146180
CountingBackend.opens = 0
147-
monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s))
181+
monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s)))
148182

149183
dialog.active_cameras_list.setCurrentRow(0)
150184
qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton)
@@ -165,10 +199,28 @@ def test_e2e_apply_settings_restarts_preview_on_restart_fields(dialog, qtbot, mo
165199
def test_e2e_apply_settings_does_not_restart_on_crop_or_rotation(dialog, qtbot, monkeypatch):
166200
"""
167201
Crop/rotation are applied live in preview; Apply should not restart backend.
168-
We validate by ensuring backend open count does not increase.
202+
We validate by ensuring open() count does not increase.
169203
"""
204+
205+
class CountingBackend(CameraBackend):
206+
opens = 0
207+
208+
def __init__(self, settings):
209+
super().__init__(settings)
210+
self._opened = False
211+
212+
def open(self):
213+
type(self).opens += 1
214+
self._opened = True
215+
216+
def close(self):
217+
self._opened = False
218+
219+
def read(self):
220+
return np.zeros((30, 40, 3), dtype=np.uint8), 0.1
221+
170222
CountingBackend.opens = 0
171-
monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s))
223+
monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s)))
172224

173225
dialog.active_cameras_list.setCurrentRow(0)
174226
qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton)
@@ -189,9 +241,13 @@ def test_e2e_apply_settings_does_not_restart_on_crop_or_rotation(dialog, qtbot,
189241
@pytest.mark.gui
190242
def test_e2e_selection_change_auto_commits(dialog, qtbot):
191243
"""
192-
Guard contract in E2E mode: switching selection commits pending edits.
193-
We add a second camera deterministically via the available list.
244+
Guard contract: switching selection commits pending edits.
245+
Use FPS (supported) rather than gain (OpenCV gain is intentionally disabled).
194246
"""
247+
# Ensure backend combo matches active cam (important for add/dup logic)
248+
_select_backend_for_active_cam(dialog, cam_row=0)
249+
250+
# Add second camera deterministically
195251
dialog._on_scan_result([DetectedCamera(index=1, label="ExtraCam")])
196252
dialog.available_cameras_list.setCurrentRow(0)
197253
qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton)
@@ -213,17 +269,15 @@ def test_e2e_selection_change_auto_commits(dialog, qtbot):
213269
@pytest.mark.gui
214270
def test_cancel_scan(dialog, qtbot, monkeypatch):
215271
def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, **kwargs):
216-
# simulate long scan that can be interrupted
217272
for i in range(50):
218273
if should_cancel and should_cancel():
219274
break
220275
if progress_cb:
221276
progress_cb(f"Scanning… {i}")
222277
time.sleep(0.02)
223-
# Return something (could be empty if canceled early)
224278
return [DetectedCamera(index=0, label=f"{backend}-X")]
225279

226-
monkeypatch.setattr(CameraFactory, "detect_cameras", slow_detect)
280+
monkeypatch.setattr(CameraFactory, "detect_cameras", staticmethod(slow_detect))
227281

228282
qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton)
229283
qtbot.waitUntil(lambda: dialog.scan_cancel_btn.isVisible(), timeout=1000)
@@ -233,19 +287,16 @@ def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, *
233287
with qtbot.waitSignal(dialog.scan_finished, timeout=3000):
234288
pass
235289

236-
# UI should be re-enabled after finish
237290
assert dialog.refresh_btn.isEnabled()
238291
assert dialog.backend_combo.isEnabled()
239292

240293

241-
def _select_backend(dialog, backend_name: str):
242-
idx = dialog.backend_combo.findData(backend_name)
243-
assert idx >= 0, f"Backend {backend_name} not present"
244-
dialog.backend_combo.setCurrentIndex(idx)
245-
246-
247294
@pytest.mark.gui
248-
def test_duplicate_camera_prevented(dialog, qtbot, monkeypatch, temp_backend):
295+
def test_duplicate_camera_prevented(dialog, qtbot, monkeypatch):
296+
"""
297+
Duplicate detection compares identity keys including backend.
298+
Ensure backend combo is set to match existing active camera backend.
299+
"""
249300
calls = {"n": 0}
250301

251302
def _warn(parent, title, text, *args, **kwargs):
@@ -254,12 +305,12 @@ def _warn(parent, title, text, *args, **kwargs):
254305

255306
monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn))
256307

257-
# Ensure the available list is interpreted as "opencv" (identity key uses backend)
258-
_select_backend(dialog, "opencv")
308+
backend = _select_backend_for_active_cam(dialog, cam_row=0)
259309

260310
initial_count = dialog.active_cameras_list.count()
261311

262-
dialog._on_scan_result([DetectedCamera(index=0, label="opencv-X")])
312+
# Same backend + same index -> duplicate
313+
dialog._on_scan_result([DetectedCamera(index=0, label=f"{backend}-X")])
263314
dialog.available_cameras_list.setCurrentRow(0)
264315

265316
qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton)
@@ -269,7 +320,10 @@ def _warn(parent, title, text, *args, **kwargs):
269320

270321

271322
@pytest.mark.gui
272-
def test_max_cameras_prevented(qtbot, monkeypatch, patch_factory):
323+
def test_max_cameras_prevented(qtbot, monkeypatch, patch_detect_cameras):
324+
"""
325+
Dialog enforces MAX_CAMERAS enabled cameras. Use backend='fake' for stability.
326+
"""
273327
calls = {"n": 0}
274328

275329
def _warn(parent, title, text, *args, **kwargs):
@@ -280,28 +334,31 @@ def _warn(parent, title, text, *args, **kwargs):
280334

281335
s = MultiCameraSettings(
282336
cameras=[
283-
CameraSettings(name="C0", backend="opencv", index=0, enabled=True),
284-
CameraSettings(name="C1", backend="opencv", index=1, enabled=True),
285-
CameraSettings(name="C2", backend="opencv", index=2, enabled=True),
286-
CameraSettings(name="C3", backend="opencv", index=3, enabled=True),
337+
CameraSettings(name="C0", backend="fake", index=0, enabled=True),
338+
CameraSettings(name="C1", backend="fake", index=1, enabled=True),
339+
CameraSettings(name="C2", backend="fake", index=2, enabled=True),
340+
CameraSettings(name="C3", backend="fake", index=3, enabled=True),
287341
]
288342
)
289343
d = CameraConfigDialog(None, s)
290344
qtbot.addWidget(d)
291345
d.show()
292346
qtbot.waitExposed(d)
293347

294-
initial_count = d.active_cameras_list.count()
348+
try:
349+
_select_backend_for_active_cam(d, cam_row=0)
295350

296-
d._on_scan_result([DetectedCamera(index=4, label="Extra")])
297-
d.available_cameras_list.setCurrentRow(0)
351+
initial_count = d.active_cameras_list.count()
298352

299-
qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton)
353+
d._on_scan_result([DetectedCamera(index=4, label="Extra")])
354+
d.available_cameras_list.setCurrentRow(0)
300355

301-
assert d.active_cameras_list.count() == initial_count
302-
assert calls["n"] >= 1
356+
qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton)
303357

304-
d.reject()
358+
assert d.active_cameras_list.count() == initial_count
359+
assert calls["n"] >= 1
360+
finally:
361+
d.reject()
305362

306363

307364
@pytest.mark.gui
@@ -321,11 +378,13 @@ def test_ok_auto_applies_pending_edits(dialog, qtbot):
321378

322379
@pytest.mark.gui
323380
def test_cancel_loading_preview_button(dialog, qtbot, monkeypatch):
324-
# Make loading slow so Cancel Loading has time to work deterministically
381+
"""
382+
Deterministic cancel-loading test: slow down worker so Cancel Loading can interrupt.
383+
"""
325384

326385
def slow_run(self):
327386
self.progress.emit("Creating backend…")
328-
time.sleep(0.2) # give test time to click cancel
387+
time.sleep(0.2)
329388
if getattr(self, "_cancel", False):
330389
self.canceled.emit()
331390
return
@@ -343,10 +402,9 @@ def slow_run(self):
343402

344403
qtbot.waitUntil(lambda: dialog._loading_active, timeout=1000)
345404

346-
# Click again quickly => Cancel Loading
405+
# Click again => Cancel Loading
347406
qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton)
348407

349-
# Ensure loader goes away and preview doesn't become active
350408
qtbot.waitUntil(lambda: dialog._loader is None and not dialog._loading_active, timeout=2000)
351409
assert dialog._preview_active is False
352410
assert dialog._preview_backend is None

0 commit comments

Comments
 (0)