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>
428 lines
16 KiB
Python
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()
|