"""Tests für sn_basis/modules/LayerLoader.py (Layer erzeugen/filtern/laden, Issue #63).""" import unittest from typing import Any, List, cast from unittest.mock import MagicMock, patch from sn_basis.modules.LayerLoader import LayerLoader from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class _AufzeichnenderPruefmanager: def __init__(self) -> None: self.ergebnisse: List[Any] = [] def verarbeite(self, ergebnis: Any): self.ergebnisse.append(ergebnis) return ergebnis class _FakeCrs: def __init__(self, authid: str) -> None: self._authid = authid def authid(self) -> str: return self._authid class _FakeGeometry: def __init__(self, empty: bool = False) -> None: self.transformed = False self._empty = empty def clone(self) -> "_FakeGeometry": return _FakeGeometry(self._empty) def transform(self, ct: Any) -> None: self.transformed = True def combine(self, other: "_FakeGeometry") -> "_FakeGeometry": return self def isEmpty(self) -> bool: return self._empty def boundingBox(self) -> str: return "BBOX" def intersects(self, other: Any) -> bool: return True class _FakeFeature: def __init__(self, geometry: Any = None) -> None: self._geometry = geometry if geometry is not None else _FakeGeometry() def geometry(self) -> Any: return self._geometry class _FakeDataProvider: def __init__(self) -> None: self.added_features: List[Any] = [] self.added_attributes: Any = None def addAttributes(self, fields: Any) -> None: self.added_attributes = fields def addFeatures(self, features: Any) -> None: self.added_features = list(features) class _FakeFilteredLayer: """Ersetzt QgsVectorLayer sowohl als Klasse (VectorLayer-Konstante) als auch als Konstruktor.""" VectorLayer = 1 def __init__(self, uri: Any = None, name: Any = None, provider: Any = None) -> None: self.uri = uri self._name = name self._valid = True self._provider = _FakeDataProvider() def isValid(self) -> bool: return self._valid def dataProvider(self) -> _FakeDataProvider: return self._provider def name(self) -> Any: return self._name def updateFields(self) -> None: return None def updateExtents(self) -> None: return None class _FakeSourceLayer: def __init__( self, features: Any = None, valid: bool = True, layer_type: int = 1, geometry_type: int = 2, authid: str = "EPSG:25833", name: str = "Quelle", ) -> None: self._features = features if features is not None else [] self._valid = valid self._type = layer_type self._geometry_type = geometry_type self._crs = _FakeCrs(authid) self._name = name def isValid(self) -> bool: return self._valid def type(self) -> int: return self._type def getFeatures(self, request: Any = None) -> List[Any]: return list(self._features) def geometryType(self) -> int: return self._geometry_type def crs(self) -> _FakeCrs: return self._crs def name(self) -> str: return self._name def fields(self) -> list: return ["feld1"] def _make_loader() -> tuple[LayerLoader, _AufzeichnenderPruefmanager]: pruefmanager = _AufzeichnenderPruefmanager() return LayerLoader(pruefmanager=cast(Any, pruefmanager)), pruefmanager class CreateLayerTests(unittest.TestCase): @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_wfs_baut_uri_ohne_getfeature_parameter(self, mock_cls): """Prüft, dass für WFS die GetFeature-spezifischen Parameter aus der URI entfernt werden.""" mock_cls.return_value = MagicMock(isValid=lambda: True) loader, _pm = _make_loader() loader.create_layer("wfs", "http://server/wfs?typename=Flurstueck&request=GetFeature&BBOX=1,2,3,4", "Thema") uri = mock_cls.call_args[0][0] self.assertIn("url=http://server/wfs?", uri) self.assertIn("typename=Flurstueck", uri) self.assertNotIn("request=", uri) self.assertNotIn("BBOX", uri) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_wfs_mit_url_praefix_wird_nicht_doppelt_gesetzt(self, mock_cls): """Prüft, dass ein bereits vorhandenes 'url='-Präfix im WFS-Link nicht verdoppelt wird.""" mock_cls.return_value = MagicMock(isValid=lambda: True) loader, _pm = _make_loader() loader.create_layer("wfs", "url=http://server/wfs?typename=Flurstueck", "Thema") uri = mock_cls.call_args[0][0] self.assertNotIn("url=url=", uri) @patch("sn_basis.modules.LayerLoader.QgsRasterLayer") def test_wms_baut_url_praefix_und_nutzt_rasterlayer(self, mock_cls): """Prüft, dass WMS-Layer über QgsRasterLayer mit 'url='-Präfix erzeugt werden.""" mock_cls.return_value = MagicMock(isValid=lambda: True) loader, _pm = _make_loader() loader.create_layer("wms", "http://server/wms", "Thema") uri = mock_cls.call_args[0][0] self.assertEqual(uri, "url=http://server/wms") @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_rest_featureserver_ergaenzt_layer_index(self, mock_cls): """Prüft, dass ein REST-Link auf '.../FeatureServer' automatisch um '/0' ergänzt wird.""" mock_cls.return_value = MagicMock(isValid=lambda: True) loader, _pm = _make_loader() loader.create_layer("rest", "http://server/arcgis/rest/services/x/FeatureServer", "Thema") uri = mock_cls.call_args[0][0] self.assertTrue(uri.startswith("url=") and uri.endswith("/FeatureServer/0")) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_ogr_provider_nutzt_link_direkt(self, mock_cls): """Prüft, dass ein OGR-Provider den Link unverändert an QgsVectorLayer übergibt.""" mock_cls.return_value = MagicMock(isValid=lambda: True) loader, _pm = _make_loader() loader.create_layer("ogr", "C:/daten/flurstuecke.gpkg", "Thema") mock_cls.assert_called_once_with("C:/daten/flurstuecke.gpkg", "Thema", "ogr") @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_ungueltiger_layer_meldet_layer_nicht_verfuegbar(self, mock_cls): """Prüft, dass ein ungültig erzeugter Layer als 'layer_nicht_verfuegbar' gemeldet wird und None liefert.""" mock_cls.return_value = MagicMock(isValid=lambda: False) loader, pruefmanager = _make_loader() ergebnis = loader.create_layer("ogr", "C:/daten/x.gpkg", "Thema") self.assertIsNone(ergebnis) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("layer_nicht_verfuegbar", aktionen) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer") def test_ausnahme_beim_erstellen_wird_abgefangen(self, mock_cls): """Prüft, dass eine Ausnahme beim Layer-Erstellen abgefangen und als Fehlerergebnis gemeldet wird.""" mock_cls.side_effect = RuntimeError("kaputt") loader, pruefmanager = _make_loader() ergebnis = loader.create_layer("ogr", "C:/daten/x.gpkg", "Thema") self.assertIsNone(ergebnis) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("layer_nicht_verfuegbar", aktionen) class TransformGeometryTests(unittest.TestCase): def test_fehlender_layer_liefert_geometrie_unveraendert(self): """Prüft, dass ohne Quell- oder Ziel-Layer die Geometrie unverändert zurückgegeben wird.""" loader, _pm = _make_loader() geom = _FakeGeometry() ergebnis = loader._transform_geometry_to_layer_crs(geom, None, _FakeSourceLayer()) self.assertIs(ergebnis, geom) def test_gleiche_crs_liefert_geometrie_unveraendert(self): """Prüft, dass bei identischem CRS von Quelle und Ziel keine Transformation stattfindet.""" loader, _pm = _make_loader() geom = _FakeGeometry() quelle = _FakeSourceLayer(authid="EPSG:25833") ziel = _FakeSourceLayer(authid="EPSG:25833") ergebnis = loader._transform_geometry_to_layer_crs(geom, quelle, ziel) self.assertIs(ergebnis, geom) self.assertFalse(geom.transformed) def test_unterschiedliche_crs_klont_und_transformiert_geometrie(self): """Prüft, dass bei unterschiedlichem CRS eine geklonte, transformierte Geometrie geliefert wird.""" loader, _pm = _make_loader() geom = _FakeGeometry() quelle = _FakeSourceLayer(authid="EPSG:4326") ziel = _FakeSourceLayer(authid="EPSG:25833") ergebnis = loader._transform_geometry_to_layer_crs(geom, quelle, ziel) self.assertIsNot(ergebnis, geom) self.assertTrue(ergebnis.transformed) class TransformExtentTests(unittest.TestCase): def test_gleiche_crs_liefert_extent_unveraendert(self): """Prüft, dass bei identischem CRS von Quelle und Ziel der Extent unverändert bleibt.""" loader, _pm = _make_loader() quelle = _FakeSourceLayer(authid="EPSG:25833") ziel = _FakeSourceLayer(authid="EPSG:25833") ergebnis = loader._transform_extent_to_layer_crs("EXTENT", quelle, ziel) self.assertEqual(ergebnis, "EXTENT") def test_unterschiedliche_crs_nutzt_coordinate_transform(self): """Prüft, dass bei unterschiedlichem CRS der Extent über QgsCoordinateTransform umgerechnet wird.""" class _FakeTransform: def __init__(self, source_crs: Any, target_crs: Any, project: Any) -> None: pass def transformBoundingBox(self, rect: Any) -> str: return "TRANSFORMIERT" loader, _pm = _make_loader() quelle = _FakeSourceLayer(authid="EPSG:4326") ziel = _FakeSourceLayer(authid="EPSG:25833") with patch("sn_basis.modules.LayerLoader.QgsCoordinateTransform", _FakeTransform): ergebnis = loader._transform_extent_to_layer_crs("EXTENT", quelle, ziel) self.assertEqual(ergebnis, "TRANSFORMIERT") class FilterByExtentTests(unittest.TestCase): def test_ungueltiger_layer_wird_unveraendert_zurueckgegeben(self): """Prüft, dass ein ungültiger Layer von filter_by_extent unverändert zurückgegeben wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(valid=False) self.assertIs(loader.filter_by_extent(layer, "EXTENT"), layer) def test_ohne_extent_wird_layer_unveraendert_zurueckgegeben(self): """Prüft, dass ohne Extent (None) der Layer unverändert zurückgegeben wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer() self.assertIs(loader.filter_by_extent(layer, None), layer) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_falscher_layertyp_wird_unveraendert_zurueckgegeben(self): """Prüft, dass ein Nicht-Vektor-Layer (z. B. Rasterlayer) unverändert zurückgegeben wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(layer_type=2) self.assertIs(loader.filter_by_extent(layer, "EXTENT"), layer) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_keine_treffer_liefert_none(self): """Prüft, dass ein Raumfilter ohne Treffer None statt eines leeren Layers liefert.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(features=[]) self.assertIsNone(loader.filter_by_extent(layer, "EXTENT")) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_abbruch_liefert_none_und_meldet_needs_user_action(self): """Prüft, dass ein Abbruch während des Raumfilters None liefert und gemeldet wird.""" loader, pruefmanager = _make_loader() layer = _FakeSourceLayer(features=[_FakeFeature()]) ergebnis = loader.filter_by_extent(layer, "EXTENT", cancel_callback=lambda: True) self.assertIsNone(ergebnis) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("needs_user_action", aktionen) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_erfolgreicher_filter_liefert_neuen_layer_mit_features(self): """Prüft, dass ein erfolgreicher BBOX-Filter einen neuen Speicherlayer mit den gefundenen Features liefert.""" loader, _pm = _make_loader() features = [_FakeFeature(), _FakeFeature()] layer = _FakeSourceLayer(features=features, name="Flurstuecke") ergebnis = loader.filter_by_extent(layer, "EXTENT") self.assertIsInstance(ergebnis, _FakeFilteredLayer) self.assertEqual(ergebnis.name(), "Flurstuecke_bbox") self.assertEqual(len(ergebnis.dataProvider().added_features), 2) class FilterByLayerTests(unittest.TestCase): def test_ungueltiger_layer_oder_filterlayer_wird_unveraendert_zurueckgegeben(self): """Prüft, dass ein ungültiger Haupt- oder Filter-Layer unverändert zurückgegeben wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer() ungueltiger_filter = _FakeSourceLayer(valid=False) self.assertIs(loader.filter_by_layer(layer, ungueltiger_filter), layer) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_filterlayer_ohne_features_liefert_none(self): """Prüft, dass ein Filter-Layer ohne Features (leere Vereinigungsgeometrie) None liefert.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(features=[_FakeFeature()]) filter_layer = _FakeSourceLayer(features=[]) self.assertIsNone(loader.filter_by_layer(layer, filter_layer)) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_erfolgreicher_geometriefilter_liefert_neuen_layer(self): """Prüft, dass ein erfolgreicher Geometriefilter einen neuen Speicherlayer mit den passenden Features liefert.""" loader, _pm = _make_loader() filter_layer = _FakeSourceLayer(features=[_FakeFeature()], name="Verfahrensgebiet") layer = _FakeSourceLayer(features=[_FakeFeature(), _FakeFeature()], name="Flurstuecke") ergebnis = loader.filter_by_layer(layer, filter_layer) self.assertIsInstance(ergebnis, _FakeFilteredLayer) self.assertEqual(ergebnis.name(), "Flurstuecke_filtered") self.assertEqual(len(ergebnis.dataProvider().added_features), 2) @patch("sn_basis.modules.LayerLoader.QgsVectorLayer", _FakeFilteredLayer) def test_keine_ueberschneidung_liefert_none(self): """Prüft, dass ohne überschneidende Features None statt eines leeren Layers geliefert wird.""" class _NichtSchneidendeGeometrie(_FakeGeometry): def intersects(self, other: Any) -> bool: return False loader, _pm = _make_loader() filter_layer = _FakeSourceLayer(features=[_FakeFeature()], name="Verfahrensgebiet") layer = _FakeSourceLayer(features=[_FakeFeature(_NichtSchneidendeGeometrie())], name="Flurstuecke") self.assertIsNone(loader.filter_by_layer(layer, filter_layer)) class AddToProjectTests(unittest.TestCase): @patch("sn_basis.modules.LayerLoader.QgsProject") def test_gueltiger_layer_wird_dem_projekt_hinzugefuegt(self, mock_project): """Prüft, dass ein gültiger Layer über QgsProject.instance().addMapLayer hinzugefügt wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(valid=True) loader.add_to_project(cast(Any, layer)) mock_project.instance.return_value.addMapLayer.assert_called_once_with(layer) @patch("sn_basis.modules.LayerLoader.QgsProject") def test_ungueltiger_layer_wird_nicht_hinzugefuegt(self, mock_project): """Prüft, dass ein ungültiger Layer nicht ans Projekt übergeben wird.""" loader, _pm = _make_loader() layer = _FakeSourceLayer(valid=False) loader.add_to_project(cast(Any, layer)) mock_project.instance.return_value.addMapLayer.assert_not_called() @patch("sn_basis.modules.LayerLoader.QgsProject") def test_none_layer_wird_nicht_hinzugefuegt(self, mock_project): """Prüft, dass ein fehlender Layer (None) nicht ans Projekt übergeben wird.""" loader, _pm = _make_loader() loader.add_to_project(cast(Any, None)) mock_project.instance.return_value.addMapLayer.assert_not_called() if __name__ == "__main__": unittest.main()