1414from dlclivegui .config import CameraSettings , MultiCameraSettings
1515from 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):
141157def 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
165199def 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
190242def 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
214270def 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
323380def 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