Skip to content

Commit 582afd4

Browse files
committed
Enhance fake GenTL/Aravis test fixtures
Make test backend mocks more robust for SDK-less unit tests: add numpy import and a stricter Aravis availability check (require gi and Aravis typelib), expose HARVESTERS_AVAILABLE, and refine force_pypylon_unavailable to set pylon=None. Replace the simple FakeHarvester with a richer fake GenTL implementation (device info adapter, node/node_map, image acquirer, payload/components, timeout exception) that supports create()/create_image_acquirer(), start/stop/fetch and realistic buffer payloads. Patch GenTLCameraBackend to avoid CTI file searching during tests. Also remove pytest.mark.integration markers from many aravis tests so they run as unit tests.
1 parent ab3cfc6 commit 582afd4

2 files changed

Lines changed: 268 additions & 41 deletions

File tree

tests/cameras/backends/conftest.py

Lines changed: 268 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# tests/cameras/backends/conftest.py
22
import importlib
33
import os
4-
from types import SimpleNamespace
54

5+
import numpy as np
66
import pytest
77

88

@@ -17,8 +17,25 @@ def _has_module(name: str) -> bool:
1717
return False
1818

1919

20-
ARAVIS_AVAILABLE = _has_module("gi") # Aravis via GObject introspection
21-
PYPYLON_AVAILABLE = _has_module("pypylon") # Basler pypylon SDK
20+
def _has_aravis_gi() -> bool:
21+
"""
22+
GI can exist without the Aravis typelib. Be representative:
23+
check that gi.repository.Aravis is importable and versionable.
24+
"""
25+
try:
26+
import gi # type: ignore
27+
28+
gi.require_version("Aravis", "0.8")
29+
from gi.repository import Aravis # noqa: F401
30+
31+
return True
32+
except Exception:
33+
return False
34+
35+
36+
ARAVIS_AVAILABLE = _has_aravis_gi()
37+
PYPYLON_AVAILABLE = _has_module("pypylon")
38+
HARVESTERS_AVAILABLE = _has_module("harvesters")
2239

2340

2441
# -----------------------------
@@ -109,23 +126,21 @@ def force_aravis_unavailable(monkeypatch):
109126
def force_pypylon_unavailable(monkeypatch):
110127
"""
111128
Force Basler/pypylon to be unavailable for error-path testing.
129+
Basler backend availability is based on 'pylon is not None'.
112130
"""
113131
try:
114132
import dlclivegui.cameras.backends.basler_backend as bas
115133
except Exception:
116-
# If the module doesn't exist in your tree, ignore.
117134
yield
118135
return
119-
monkeypatch.setattr(bas, "PYPYLON_AVAILABLE", False, raising=False)
136+
120137
monkeypatch.setattr(bas, "pylon", None, raising=False)
121138
yield
122139

123140

124141
# -----------------------------------------------------------------------------
125142
# Fake Aravis SDK (module-like) + fixtures
126143
# -----------------------------------------------------------------------------
127-
128-
129144
class FakeAravis:
130145
"""Minimal fake Aravis module used for SDK-less unit/contract tests."""
131146

@@ -495,61 +510,290 @@ def patch_basler_sdk(monkeypatch, fake_pylon_module):
495510

496511

497512
# -----------------------------------------------------------------------------
498-
# Fake GenTL / harvesters SDK (module-like) + fixtures
513+
# Fake GenTL / harvesters SDK (open/read/close capable) + fixtures
499514
# -----------------------------------------------------------------------------
500515

501516

502-
class FakeHarvesterTimeoutError(TimeoutError):
517+
class FakeGenTLTimeoutException(TimeoutError):
518+
"""
519+
Representative timeout: Harvesters often surfaces GenTL TimeoutException semantics.
520+
"""
521+
503522
pass
504523

505524

525+
class _DeviceInfoAdapter:
526+
"""
527+
Make device_info_list entries behave whether they're dict-like or object-like.
528+
"""
529+
530+
def __init__(self, payload):
531+
self._payload = payload
532+
533+
def get(self, key, default=None):
534+
if isinstance(self._payload, dict):
535+
return self._payload.get(key, default)
536+
return getattr(self._payload, key, default)
537+
538+
@property
539+
def serial_number(self):
540+
return self.get("serial_number", "")
541+
542+
@property
543+
def vendor(self):
544+
return self.get("vendor", "")
545+
546+
@property
547+
def model(self):
548+
return self.get("model", "")
549+
550+
@property
551+
def display_name(self):
552+
return self.get("display_name", "")
553+
554+
555+
class _FakeNode:
556+
"""
557+
Minimal GenICam-style node with .value and optional constraints.
558+
Harvesters exposes nodes as objects; your backend uses:
559+
- node.value
560+
- node.min / node.max / node.inc (for Width/Height)
561+
- PixelFormat.symbolics (for allowed formats)
562+
"""
563+
564+
def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None):
565+
self.value = value
566+
self.min = min
567+
self.max = max
568+
self.inc = inc
569+
self.symbolics = symbolics or []
570+
571+
572+
class _FakeNodeMap:
573+
"""Provides attribute access for nodes used by GenTLCameraBackend."""
574+
575+
def __init__(self, *, width=1920, height=1080, fps=30.0, exposure=10000.0, gain=0.0, pixel_format="Mono8"):
576+
# Identification / label fields your _resolve_device_label() tries
577+
self.DeviceModelName = _FakeNode("FakeGenTLModel")
578+
self.DeviceSerialNumber = _FakeNode("FAKE-GENTL-0")
579+
self.DeviceDisplayName = _FakeNode("FakeGenTLDisplay")
580+
581+
# Format + acquisition nodes
582+
self.PixelFormat = _FakeNode(
583+
pixel_format,
584+
symbolics=["Mono8", "Mono16", "RGB8", "BGR8"],
585+
)
586+
587+
# Width/Height with constraints for increment alignment logic
588+
self.Width = _FakeNode(int(width), min=64, max=4096, inc=2)
589+
self.Height = _FakeNode(int(height), min=64, max=4096, inc=2)
590+
591+
# FPS related nodes (backend may set AcquisitionFrameRate)
592+
self.AcquisitionFrameRateEnable = _FakeNode(True)
593+
self.AcquisitionFrameRate = _FakeNode(float(fps))
594+
# backend tries ResultingFrameRate for actual FPS; provide it
595+
self.ResultingFrameRate = _FakeNode(float(fps))
596+
597+
# Exposure/Gain
598+
self.ExposureAuto = _FakeNode("Off")
599+
self.ExposureTime = _FakeNode(float(exposure))
600+
self.GainAuto = _FakeNode("Off")
601+
self.Gain = _FakeNode(float(gain))
602+
603+
604+
class _FakeRemoteDevice:
605+
def __init__(self, node_map: _FakeNodeMap):
606+
self.node_map = node_map
607+
608+
609+
class _FakeComponent:
610+
def __init__(self, width: int, height: int, channels: int, dtype=np.uint8):
611+
self.width = int(width)
612+
self.height = int(height)
613+
self._channels = int(channels)
614+
self._dtype = dtype
615+
616+
# Create a deterministic image payload
617+
n = self.width * self.height * self._channels
618+
if dtype == np.uint8:
619+
arr = (np.arange(n) % 255).astype(np.uint8)
620+
else:
621+
# e.g., uint16
622+
arr = (np.arange(n) % 65535).astype(np.uint16)
623+
624+
# Harvesters often exposes component.data as a buffer-like object;
625+
# your backend does np.asarray(component.data) and may fall back to frombuffer(bytes(...)).
626+
# A numpy array works fine for both.
627+
self.data = arr
628+
629+
630+
class _FakePayload:
631+
def __init__(self, component: _FakeComponent):
632+
self.components = [component]
633+
634+
635+
class _FakeFetchedBufferCtx:
636+
"""
637+
Context manager returned by FakeImageAcquirer.fetch().
638+
Must provide .payload with components.
639+
"""
640+
641+
def __init__(self, payload: _FakePayload):
642+
self.payload = payload
643+
644+
def __enter__(self):
645+
return self
646+
647+
def __exit__(self, exc_type, exc, tb):
648+
return False
649+
650+
651+
class FakeImageAcquirer:
652+
"""
653+
Minimal Harvesters image acquirer:
654+
- remote_device.node_map
655+
- start()/stop()/destroy()
656+
- fetch(timeout=...) -> context manager
657+
- node_map shortcut (your backend uses self._acquirer.node_map in read())
658+
"""
659+
660+
def __init__(self, *, serial="FAKE-GENTL-0", width=1920, height=1080, pixel_format="Mono8"):
661+
self.serial = serial
662+
self._started = False
663+
self._destroyed = False
664+
665+
# Node map used by open() and read()
666+
self.remote_device = _FakeRemoteDevice(_FakeNodeMap(width=width, height=height, pixel_format=pixel_format))
667+
self.node_map = self.remote_device.node_map
668+
669+
# Simple FIFO of frames (buffers)
670+
self._queue: list[_FakePayload] = []
671+
self._populate_default_frames()
672+
673+
def _populate_default_frames(self):
674+
# Make one frame available by default
675+
pf = str(self.node_map.PixelFormat.value or "Mono8")
676+
if pf in ("RGB8", "BGR8"):
677+
channels = 3
678+
dtype = np.uint8
679+
elif pf == "Mono16":
680+
channels = 1
681+
dtype = np.uint16
682+
else:
683+
channels = 1
684+
dtype = np.uint8
685+
686+
comp = _FakeComponent(self.node_map.Width.value, self.node_map.Height.value, channels, dtype=dtype)
687+
self._queue.append(_FakePayload(comp))
688+
689+
def start(self):
690+
self._started = True
691+
692+
def stop(self):
693+
self._started = False
694+
695+
def destroy(self):
696+
self._destroyed = True
697+
698+
def fetch(self, timeout: float = 2.0):
699+
if not self._started:
700+
raise FakeGenTLTimeoutException("Acquirer not started")
701+
702+
if not self._queue:
703+
raise FakeGenTLTimeoutException(f"Timeout after {timeout}s")
704+
705+
payload = self._queue.pop(0)
706+
return _FakeFetchedBufferCtx(payload)
707+
708+
506709
class FakeHarvester:
507710
"""
508-
Minimal fake for 'from harvesters.core import Harvester' usage.
711+
Minimal fake for 'from harvesters.core import Harvester' supporting:
712+
- add_file/update/reset
713+
- device_info_list for enumeration
714+
- create()/create_image_acquirer() returning FakeImageAcquirer
509715
510-
Enough for:
511-
- is_available()
512-
- get_device_count() flow (Harvester() -> add_file -> update -> device_info_list -> reset)
716+
This enables GenTLCameraBackend.open/read/close paths.
513717
"""
514718

515719
def __init__(self):
516720
self.device_info_list = []
517721
self._files = []
722+
self._acquirers: list[FakeImageAcquirer] = []
518723

519724
def add_file(self, file_path: str):
520725
self._files.append(str(file_path))
521726

522727
def update(self):
523-
# Expose at least one device info entry
524-
self.device_info_list = [SimpleNamespace(serial_number="FAKE-GENTL-0")]
728+
# Harvesters tutorial output shows dict-like device entries.
729+
self.device_info_list = [
730+
{
731+
"display_name": "TLSimuMono (FAKE-GENTL-0)",
732+
"model": "FakeGenTLModel",
733+
"vendor": "FakeVendor",
734+
"serial_number": "FAKE-GENTL-0",
735+
"id_": "FakeDeviceId",
736+
"tl_type": "Custom",
737+
"user_defined_name": "Center",
738+
"version": "1.0.0",
739+
}
740+
]
525741

526742
def reset(self):
743+
# "release" resources
527744
self.device_info_list = []
528745
self._files = []
746+
self._acquirers = []
747+
748+
def create(self, selector=None, index: int | None = None, *args, **kwargs):
749+
serial = None
529750

530-
# Optional: creation methods referenced by GenTL backend (only needed if you test open())
531-
def create(self, *args, **kwargs):
532-
raise RuntimeError("FakeHarvester.create() not implemented for open-path tests")
751+
# Selector dict commonly used: {"serial_number": "..."} [1](https://github.com/genicam/harvesters/issues/454)
752+
if isinstance(selector, dict):
753+
serial = selector.get("serial_number")
754+
755+
if serial is None and index is None:
756+
index = 0
757+
758+
if not self.device_info_list:
759+
self.update()
760+
761+
if serial is None:
762+
if index is None:
763+
index = 0
764+
if index < 0 or index >= len(self.device_info_list):
765+
raise RuntimeError("Index out of range")
766+
info = _DeviceInfoAdapter(self.device_info_list[index])
767+
serial = info.serial_number or "FAKE-GENTL-0"
768+
769+
acq = FakeImageAcquirer(serial=serial)
770+
self._acquirers.append(acq)
771+
return acq
533772

534773
def create_image_acquirer(self, *args, **kwargs):
535-
raise RuntimeError("FakeHarvester.create_image_acquirer() not implemented for open-path tests")
774+
# Alias used by some Harvesters versions; just delegate to create()
775+
return self.create(*args, **kwargs)
536776

537777

538778
@pytest.fixture()
539779
def fake_harvester_class():
540-
"""Provides FakeHarvester class (not an instance) for patching gentl backend."""
780+
"""Provides FakeHarvester class for patching GenTL backend."""
541781
return FakeHarvester
542782

543783

544784
@pytest.fixture()
545785
def patch_gentl_sdk(monkeypatch, fake_harvester_class):
546-
"""
547-
Patch GenTL backend to behave as if harvesters is installed, using FakeHarvester.
548-
"""
549786
import dlclivegui.cameras.backends.gentl_backend as gb
550787

551788
monkeypatch.setattr(gb, "Harvester", fake_harvester_class, raising=False)
552-
monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeHarvesterTimeoutError, raising=False)
789+
monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False)
790+
791+
# Prevent CTI searching from blocking open/get_device_count
792+
monkeypatch.setattr(gb.GenTLCameraBackend, "_find_cti_file", lambda self: "dummy.cti", raising=False)
793+
monkeypatch.setattr(
794+
gb.GenTLCameraBackend, "_search_cti_file", staticmethod(lambda patterns: "dummy.cti"), raising=False
795+
)
796+
553797
return fake_harvester_class
554798

555799

0 commit comments

Comments
 (0)