44import importlib
55import logging
66import os
7+ import threading
78from dataclasses import dataclass
89from typing import Any
910
@@ -641,6 +642,122 @@ def __exit__(self, exc_type, exc, tb):
641642 return False
642643
643644
645+ class FakeSharedHarvesterPoolAcquireError (RuntimeError ):
646+ """Raised by the fake shared pool when no CTI can be loaded."""
647+
648+ def __init__ (self , message : str , * , loaded_files = None , failed_files = None ):
649+ super ().__init__ (message )
650+ self .loaded_files = list (loaded_files or [])
651+ self .failed_files = list (failed_files or [])
652+
653+
654+ class FakeSharedEntry :
655+ def __init__ (self , harvester , loaded_files , failed_files = None ):
656+ self .harvester = harvester
657+ self .loaded_files = list (loaded_files or [])
658+ self .failed_files = list (failed_files or [])
659+ self .lock = threading .RLock ()
660+
661+
662+ class FakeSharedHarvesterPool :
663+ """
664+ Test double for cti_finder.SharedHarvesterPool.
665+
666+ Important behavior:
667+ - Reuses one Harvester per normalized CTI set.
668+ - Calls update() only when creating the shared Harvester.
669+ - Does not call update() when reusing an existing shared Harvester.
670+ - Tracks loaded_files/failed_files so backend diagnostics can be tested.
671+ """
672+
673+ _entries : dict [tuple [str , ...], FakeSharedEntry ] = {}
674+ _refcounts : dict [tuple [str , ...], int ] = {}
675+ _harvester_factory = None
676+
677+ @classmethod
678+ def configure (cls , harvester_factory ):
679+ cls .reset ()
680+ cls ._harvester_factory = harvester_factory
681+
682+ @staticmethod
683+ def _key (cti_files ) -> tuple [str , ...]:
684+ # Stable across case/path spelling on Windows while preserving loaded_files separately.
685+ return tuple (os .path .normcase (os .path .abspath (str (p ))) for p in cti_files )
686+
687+ @classmethod
688+ def acquire (cls , cti_files ):
689+ key = cls ._key (cti_files )
690+
691+ if key in cls ._entries :
692+ cls ._refcounts [key ] += 1
693+ return cls ._entries [key ]
694+
695+ if cls ._harvester_factory is None :
696+ raise RuntimeError ("FakeSharedHarvesterPool is not configured" )
697+
698+ h = cls ._harvester_factory ()
699+
700+ loaded : list [str ] = []
701+ failed : list [tuple [str , str ]] = []
702+
703+ for cti in cti_files :
704+ cti_str = str (cti )
705+ try :
706+ h .add_file (cti_str )
707+ loaded .append (cti_str )
708+ except Exception as exc :
709+ failed .append ((cti_str , str (exc )))
710+
711+ if not loaded :
712+ try :
713+ h .reset ()
714+ except Exception :
715+ pass
716+ raise FakeSharedHarvesterPoolAcquireError (
717+ "No fake CTIs could be loaded" ,
718+ loaded_files = [],
719+ failed_files = failed ,
720+ )
721+
722+ h .update ()
723+
724+ entry = FakeSharedEntry (h , loaded_files = loaded , failed_files = failed )
725+ cls ._entries [key ] = entry
726+ cls ._refcounts [key ] = 1
727+ return entry
728+
729+ @classmethod
730+ def release (cls , entry ):
731+ for key , value in list (cls ._entries .items ()):
732+ if value is entry :
733+ cls ._refcounts [key ] -= 1
734+ if cls ._refcounts [key ] <= 0 :
735+ try :
736+ entry .harvester .reset ()
737+ except Exception :
738+ pass
739+ del cls ._entries [key ]
740+ del cls ._refcounts [key ]
741+ return
742+
743+ @classmethod
744+ def get_refcount (cls , entry ):
745+ for key , value in cls ._entries .items ():
746+ if value is entry :
747+ return cls ._refcounts [key ]
748+ return 0
749+
750+ @classmethod
751+ def reset (cls ):
752+ for entry in list (cls ._entries .values ()):
753+ try :
754+ entry .harvester .reset ()
755+ except Exception :
756+ pass
757+ cls ._entries .clear ()
758+ cls ._refcounts .clear ()
759+
760+
644761@dataclass
645762class FakeImageAcquirer :
646763 """
@@ -691,6 +808,7 @@ def _enqueue_default_frame(self):
691808 def start (self ):
692809 self .start_calls += 1
693810 self ._started = True
811+ self ._queue .clear ()
694812
695813 def stop (self ):
696814 self .stop_calls += 1
@@ -707,10 +825,28 @@ def fetch(self, timeout: float = 2.0):
707825 if not self ._started :
708826 raise FakeGenTLTimeoutException ("fetch called while not started" )
709827
710- if not self ._queue :
711- raise FakeGenTLTimeoutException (f"timeout after { timeout } s" )
828+ if self ._queue :
829+ payload = self ._queue .pop (0 )
830+ else :
831+ # Generate from the current node map, because backend may have changed
832+ # PixelFormat/Width/Height during open().
833+ pf = str (self .node_map .PixelFormat .value or "Mono8" )
834+ if pf in ("RGB8" , "BGR8" ):
835+ channels , dtype = 3 , np .uint8
836+ elif pf in ("Mono16" , "Mono12" , "Mono10" ):
837+ channels , dtype = 1 , np .uint16
838+ else :
839+ # Mono8 and Bayer*8 are single-channel uint8
840+ channels , dtype = 1 , np .uint8
841+
842+ comp = _FakeComponent (
843+ int (self .node_map .Width .value ),
844+ int (self .node_map .Height .value ),
845+ channels ,
846+ dtype = dtype ,
847+ )
848+ payload = _FakePayload (comp )
712849
713- payload = self ._queue .pop (0 )
714850 return _FakeFetchedBufferCtx (payload )
715851
716852
@@ -854,29 +990,49 @@ def gentl_fail_add_file_for():
854990def patch_gentl_sdk (monkeypatch , fake_harvester_factory , gentl_fail_add_file_for , tmp_path ):
855991 """
856992 Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout.
857- Ensure CTI discovery succeeds for classmethods by creating a real dummy .cti and
858- exposing it via GENICAM_GENTL64_PATH.
993+
994+ Important:
995+ The production backend now uses cti_finder.SharedHarvesterPool.acquire()
996+ during open(), so tests must patch that pool too.
859997 """
860998 import dlclivegui .cameras .backends .gentl_backend as gb
861999
862- # Patch Harvester symbol (the backend calls Harvester() directly)
1000+ # Reset and expose test counters/state.
1001+ gb .update_count = 0
1002+ gb .fail_add_file_for = gentl_fail_add_file_for
1003+
1004+ # Patch Harvester symbol for discovery/rebind paths.
8631005 monkeypatch .setattr (gb , "Harvester" , lambda : fake_harvester_factory (), raising = False )
8641006
865- # Keep timeout contract
1007+ # Count all fake update() calls.
1008+ original_update = FakeHarvester .update
1009+
1010+ def update_with_count (self ):
1011+ gb .update_count += 1
1012+ return original_update (self )
1013+
1014+ monkeypatch .setattr (FakeHarvester , "update" , update_with_count , raising = True )
1015+
1016+ # Keep timeout contract.
8661017 monkeypatch .setattr (gb , "HarvesterTimeoutError" , FakeGenTLTimeoutException , raising = False )
8671018
868- # Create a real CTI file and advertise it via env var
1019+ # Patch the shared pool used by open().
1020+ FakeSharedHarvesterPool .configure (fake_harvester_factory )
1021+ monkeypatch .setattr (gb .cti_finder , "SharedHarvesterPool" , FakeSharedHarvesterPool , raising = False )
1022+
1023+ # Create a real CTI file and advertise it via env var.
8691024 cti_file = tmp_path / "dummy.cti"
8701025 if not cti_file .exists ():
8711026 cti_file .write_text ("fake" , encoding = "utf-8" )
8721027
8731028 monkeypatch .setenv ("GENICAM_GENTL64_PATH" , str (tmp_path ))
8741029 monkeypatch .delenv ("GENICAM_GENTL32_PATH" , raising = False )
8751030
876- # OPTIONAL: expose failure control so tests can do gb.fail_add_file_for.add(...)
877- gb .fail_add_file_for = gentl_fail_add_file_for
878-
879- return gb
1031+ try :
1032+ yield gb
1033+ finally :
1034+ FakeSharedHarvesterPool .reset ()
1035+ gb .fail_add_file_for = set ()
8801036
8811037
8821038@pytest .fixture ()
0 commit comments