Files
Plugin_SN_Basis/tests/test_layerloader.py
T
DanielandClaude Sonnet 5 60d5bd7cb5 Tests für die vier Kernmodule der Datenpipeline ergänzt (#63)
70 neue Testfälle für Datenabruf.py, Datenschreiber.py, LayerLoader.py und
excel_importer.py - die einzigen Module ohne jede Testdatei laut
Testkatalog-Abschnitt "Ungetestete Funktionen". Schwerpunkt: Provider-URL-
Bau (WFS/REST/OGR), Raumfilter, CRS-Transformation, GPKG-Schreib-/Ladepfad
und Excel-Import-Fehlerzweige.

qgiscore_wrapper._MockQgsProject um transformContext() und addMapLayer()
ergänzt - beide fehlten im Mock und ließen die betroffenen Funktionen im
Mock-Modus für jeden gültigen Layer scheitern (siehe fallstricke.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:48:31 +02:00

428 lines
16 KiB
Python

"""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()