From 7ce820581f81cd0e1bb14919164db69692aabb57 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 18 Sep 2026 11:20:10 +0200 Subject: [PATCH] =?UTF-8?q?Tests=20f=C3=BCr=20restliche=20priorisierte=20s?= =?UTF-8?q?n=5Fbasis-Funktionen=20erg=C3=A4nzt=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 79 neue Testfälle: Verfahrensgebiet-Zustandsverwaltung (Listener-Setup/ -teardown, Signal-Handler), DockManager/BaseDockWidget, pruef_ergebnis- Persistenz-Gates, Pruefmanager-Entscheidungswege, restliche Dateipruefer- Zweige, Fundament-Wrapper (sys_wrapper, ly_existence_wrapper, ly_style_wrapper.apply_style_from_path, landkreise, message_wrapper, qgiscore_wrapper.polygon_zu_linie/layer_exists_in_gpkg, qgisui_wrapper.add_layer_to_project) und SettingsTab.save_data/load_data. Ausgeklammert: LayerLoader.apply_style/ly_style_wrapper.apply_style (Bug-Verdacht, hartkodierter Fachplugin-Pfad) und Pruefmanager._ask_use_or_replace_pufferlayer (bereits als Bug in #60 erfasst: ruft nicht existierendes self.pruefmanager auf). qt_wrapper._MockQDockWidget um closeEvent() ergänzt (fehlte, ließ jede BaseDockWidget.closeEvent()-Ausführung im Mock-Modus abstürzen). Co-Authored-By: Claude Sonnet 5 --- changelog.txt | 1 + functions/qt_wrapper.py | 1 + tests/test_base_dockwidget.py | 99 +++++++++++++++ tests/test_dateipruefer.py | 49 ++++++++ tests/test_dockmanager.py | 97 ++++++++++++++ tests/test_landkreise.py | 28 +++++ tests/test_ly_existence_wrapper.py | 56 +++++++++ tests/test_ly_style_wrapper.py | 54 ++++++++ tests/test_message_wrapper.py | 49 ++++++++ tests/test_pruef_ergebnis.py | 47 +++++++ tests/test_pruefmanager.py | 105 ++++++++++++++++ tests/test_qgiscore_wrapper_helpers.py | 168 +++++++++++++++++++++++++ tests/test_qgisui_wrapper_helpers.py | 38 ++++++ tests/test_settings_tab.py | 117 +++++++++++++++++ tests/test_sys_wrapper.py | 42 +++++++ tests/test_verfahrensgebiet_manager.py | 125 ++++++++++++++++++ 16 files changed, 1076 insertions(+) create mode 100644 tests/test_base_dockwidget.py create mode 100644 tests/test_dockmanager.py create mode 100644 tests/test_landkreise.py create mode 100644 tests/test_ly_existence_wrapper.py create mode 100644 tests/test_ly_style_wrapper.py create mode 100644 tests/test_message_wrapper.py create mode 100644 tests/test_pruef_ergebnis.py create mode 100644 tests/test_qgiscore_wrapper_helpers.py create mode 100644 tests/test_qgisui_wrapper_helpers.py create mode 100644 tests/test_settings_tab.py diff --git a/changelog.txt b/changelog.txt index 3fa5bbe..f4e38bb 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- Tests für weitere zentrale Funktionen ergänzt: Verfahrensgebiet-Zustandsverwaltung, Dock-Verwaltung, Pruefmanager-/Dateipruefer-Entscheidungswege, Fundament-Wrapper (Pfade, Landkreise, Nachrichten, Layer-Existenz/-Stil, GPKG-Layer-Prüfung, QGIS-3/4-Geometriekompatibilität) und Projekteigenschaften-Tab (#65) - Tests für die Kernpipeline ergänzt: Datenabruf (WFS/REST/OGR-URL-Bau, Raumfilter), Datenschreiber (GPKG-Schreiben, Laden, Log), LayerLoader (CRS-Transformation, Raum-/Geometriefilter) und excel_importer (Linklisten-Import) waren zuvor ohne Testdatei (#63) - Bugfix: `get_plugin_root()` löst keine Symlinks/Junctions mehr auf – im per `link_qgis_profile.py` verknüpften Dev-Setup zeigte die Funktion sonst auf den Workspace-Root statt auf den QGIS-Plugin-Ordner, wodurch Assets von Fachplugins (z. B. QML-Stile in sn_verfahrensgebiet/sn_plan41) nicht gefunden wurden (Verfahrensgebiet #50) - Bezeichner-Bereinigung (Layernamen) um Bindestrich ergänzt und in eine zentrale Funktion `sanitize_bezeichner` (`functions/text_wrapper.py`) ausgelagert, damit Umlaute/Sonderzeichen nicht mehr pro Aufrufstelle vergessen werden (#42) diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index a14e03e..e94917b 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -470,6 +470,7 @@ except (ImportError, AttributeError): def objectName(self) -> str: return self._object_name def show(self) -> None: pass def deleteLater(self) -> None: pass + def closeEvent(self, event) -> None: pass QDockWidget = _MockQDockWidget diff --git a/tests/test_base_dockwidget.py b/tests/test_base_dockwidget.py new file mode 100644 index 0000000..8cc342e --- /dev/null +++ b/tests/test_base_dockwidget.py @@ -0,0 +1,99 @@ +"""Tests für sn_basis/ui/base_dockwidget.py::closeEvent (Issue #65).""" + +import unittest + +from sn_basis.ui.base_dockwidget import BaseDockWidget + + +class _FakeTab: + def __init__(self, mit_cleanup: bool = True) -> None: + self.cleanup_aufgerufen = False + if mit_cleanup: + self.cleanup = self._cleanup + + def _cleanup(self) -> None: + self.cleanup_aufgerufen = True + + +class _FehlerhafterTab: + def cleanup(self) -> None: + raise RuntimeError("cleanup kaputt") + + +class _FakeTabWidget: + def __init__(self, tabs) -> None: + self._tabs = tabs + + def count(self) -> int: + return len(self._tabs) + + def widget(self, index: int): + return self._tabs[index] + + +class _FakeAction: + def __init__(self) -> None: + self.checked = None + + def setChecked(self, value: bool) -> None: + self.checked = value + + +def _neues_dock() -> BaseDockWidget: + """Erzeugt ein BaseDockWidget ohne den echten __init__ (keine Qt-Tab-Erzeugung nötig für closeEvent).""" + dock = BaseDockWidget.__new__(BaseDockWidget) + dock.action = None + return dock + + +class CloseEventTests(unittest.TestCase): + def test_ruft_cleanup_aller_tabs_auf(self): + """Prüft, dass closeEvent cleanup() auf allen Tabs des Dock-Widgets aufruft.""" + dock = _neues_dock() + tab1, tab2 = _FakeTab(), _FakeTab() + dock.widget = lambda: _FakeTabWidget([tab1, tab2]) + + dock.closeEvent(None) + + self.assertTrue(tab1.cleanup_aufgerufen) + self.assertTrue(tab2.cleanup_aufgerufen) + + def test_ignoriert_tabs_ohne_cleanup(self): + """Prüft, dass ein Tab ohne cleanup()-Methode nicht zu einem Fehler führt.""" + dock = _neues_dock() + dock.widget = lambda: _FakeTabWidget([_FakeTab(mit_cleanup=False)]) + + dock.closeEvent(None) # darf keine Ausnahme werfen + + def test_fehler_in_cleanup_wird_abgefangen_und_andere_tabs_laufen_weiter(self): + """Prüft, dass ein Fehler in cleanup() eines Tabs nicht die übrigen Tabs verhindert.""" + dock = _neues_dock() + funktionierender_tab = _FakeTab() + dock.widget = lambda: _FakeTabWidget([_FehlerhafterTab(), funktionierender_tab]) + + dock.closeEvent(None) + + self.assertTrue(funktionierender_tab.cleanup_aufgerufen) + + def test_setzt_toolbar_action_zurueck(self): + """Prüft, dass closeEvent die verknüpfte Toolbar-Action auf 'nicht gedrückt' zurücksetzt.""" + dock = _neues_dock() + dock.widget = lambda: _FakeTabWidget([]) + action = _FakeAction() + dock.action = action + + dock.closeEvent(None) + + self.assertFalse(action.checked) + + def test_ohne_action_wird_nichts_zurueckgesetzt(self): + """Prüft, dass closeEvent ohne verknüpfte Toolbar-Action keine Ausnahme wirft.""" + dock = _neues_dock() + dock.widget = lambda: _FakeTabWidget([]) + dock.action = None + + dock.closeEvent(None) # darf keine Ausnahme werfen + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dateipruefer.py b/tests/test_dateipruefer.py index f3761ed..4b9f120 100644 --- a/tests/test_dateipruefer.py +++ b/tests/test_dateipruefer.py @@ -2,6 +2,7 @@ import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import patch from sn_basis.modules.Dateipruefer import Dateipruefer @@ -111,6 +112,54 @@ class TestDateipruefer(unittest.TestCase): self.assertEqual(result.aktion, "ok") self.assertEqual(result.kontext, Path("/tmp/test.txt")) + # ----------------------------------------------------- + # 7. erkenne_dateityp + # ----------------------------------------------------- + def test_erkenne_dateityp_unterscheidet_excel_datenbank_und_unbekannt(self): + """Prüft, dass erkenne_dateityp .xlsx als 'excel', .gpkg/.sqlite als 'datenbank' und Rest als 'unbekannt' erkennt.""" + pruefer = Dateipruefer(pfad="ignoriert") + + self.assertEqual(pruefer.erkenne_dateityp(Path("liste.xlsx")), "excel") + self.assertEqual(pruefer.erkenne_dateityp(Path("daten.gpkg")), "datenbank") + self.assertEqual(pruefer.erkenne_dateityp(Path("daten.sqlite")), "datenbank") + self.assertEqual(pruefer.erkenne_dateityp(Path("text.txt")), "unbekannt") + + # ----------------------------------------------------- + # 8. _pfad: relativ vs. absolut + # ----------------------------------------------------- + def test_pfad_verbindet_relativen_pfad_mit_basispfad(self): + """Prüft, dass _pfad einen relativen Pfad mit dem Basisverzeichnis verbindet.""" + pruefer = Dateipruefer(pfad="ignoriert", basis_pfad="C:/verfahren") + + self.assertEqual(pruefer._pfad("daten.gpkg"), Path("C:/verfahren/daten.gpkg")) + + def test_pfad_laesst_absoluten_pfad_unveraendert(self): + """Prüft, dass _pfad einen bereits absoluten Pfad nicht mit dem Basisverzeichnis verbindet.""" + pruefer = Dateipruefer(pfad="ignoriert", basis_pfad="C:/verfahren") + + self.assertEqual(pruefer._pfad("/absolut/daten.gpkg"), Path("/absolut/daten.gpkg")) + + # ----------------------------------------------------- + # 9. _ist_gueltiger_gpkg_pfad + # ----------------------------------------------------- + def test_ist_gueltiger_gpkg_pfad_akzeptiert_vorhandenes_verzeichnis(self): + """Prüft, dass ein .gpkg-Pfad mit vorhandenem Zielverzeichnis als gültig gilt.""" + pruefer = Dateipruefer(pfad="ignoriert") + with TemporaryDirectory() as tmp: + self.assertTrue(pruefer._ist_gueltiger_gpkg_pfad(Path(tmp) / "verfahren.gpkg")) + + def test_ist_gueltiger_gpkg_pfad_lehnt_falsche_endung_ab(self): + """Prüft, dass ein Pfad ohne .gpkg-Endung als ungültig gilt, auch bei vorhandenem Verzeichnis.""" + pruefer = Dateipruefer(pfad="ignoriert") + with TemporaryDirectory() as tmp: + self.assertFalse(pruefer._ist_gueltiger_gpkg_pfad(Path(tmp) / "verfahren.txt")) + + def test_ist_gueltiger_gpkg_pfad_lehnt_fehlendes_verzeichnis_ab(self): + """Prüft, dass ein .gpkg-Pfad in einem nicht vorhandenen Verzeichnis als ungültig gilt.""" + pruefer = Dateipruefer(pfad="ignoriert") + + self.assertFalse(pruefer._ist_gueltiger_gpkg_pfad(Path("C:/nicht/vorhanden/verfahren.gpkg"))) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dockmanager.py b/tests/test_dockmanager.py new file mode 100644 index 0000000..d53972c --- /dev/null +++ b/tests/test_dockmanager.py @@ -0,0 +1,97 @@ +"""Tests für sn_basis/ui/dockmanager.py (nur ein Dock gleichzeitig sichtbar, Issue #65).""" + +import unittest +from unittest.mock import patch + +from sn_basis.ui.dockmanager import DockManager +from sn_basis.functions.qt_wrapper import DockAreaRight + + +class _FakeDock: + def __init__(self, name: str = "") -> None: + self._name = name + self.shown = False + + def objectName(self) -> str: + return self._name + + def setObjectName(self, name: str) -> None: + self._name = name + + def show(self) -> None: + self.shown = True + + def deleteLater(self) -> None: + return None + + +class DockManagerShowTests(unittest.TestCase): + @patch("sn_basis.ui.dockmanager.error") + @patch("sn_basis.ui.dockmanager.add_dock_widget") + def test_none_dock_meldet_fehler_und_zeigt_nichts_an(self, mock_add, mock_error): + """Prüft, dass ein fehlendes Dock-Widget (None) einen Fehler meldet und nichts anzeigt.""" + DockManager.show(None) + + mock_error.assert_called_once() + mock_add.assert_not_called() + + @patch("sn_basis.ui.dockmanager.find_dock_widgets", return_value=[]) + @patch("sn_basis.ui.dockmanager.add_dock_widget") + def test_setzt_objectname_wenn_leer(self, mock_add, mock_find): + """Prüft, dass ein Dock ohne Namen einen technischen Namen mit dem Präfix 'sn_dock_' erhält.""" + dock = _FakeDock(name="") + + DockManager.show(dock) + + self.assertTrue(dock.objectName().startswith("sn_dock_")) + + @patch("sn_basis.ui.dockmanager.remove_dock_widget") + @patch("sn_basis.ui.dockmanager.add_dock_widget") + def test_entfernt_andere_sn_docks_aber_nicht_sich_selbst(self, mock_add, mock_remove): + """Prüft, dass andere sn_basis-Docks entfernt werden, das neue Dock und fremde Widgets aber unangetastet bleiben.""" + neues_dock = _FakeDock(name="sn_dock_neu") + altes_dock = _FakeDock(name="sn_dock_alt") + fremdes_widget = _FakeDock(name="irgendein_anderes_widget") + + with patch("sn_basis.ui.dockmanager.find_dock_widgets", return_value=[neues_dock, altes_dock, fremdes_widget]): + DockManager.show(neues_dock) + + mock_remove.assert_called_once_with(altes_dock) + + @patch("sn_basis.ui.dockmanager.find_dock_widgets", return_value=[]) + @patch("sn_basis.ui.dockmanager.add_dock_widget") + def test_zeigt_neues_dock_mit_default_area_an(self, mock_add, mock_find): + """Prüft, dass ein neues Dock mit der Standard-Area (DockAreaRight) angezeigt wird.""" + dock = _FakeDock(name="sn_dock_x") + + DockManager.show(dock) + + mock_add.assert_called_once_with(DockAreaRight, dock) + self.assertTrue(dock.shown) + + @patch("sn_basis.ui.dockmanager.warning") + @patch("sn_basis.ui.dockmanager.add_dock_widget") + @patch("sn_basis.ui.dockmanager.find_dock_widgets", side_effect=RuntimeError("kaputt")) + def test_fehler_beim_entfernen_wird_als_warnung_gemeldet(self, mock_find, mock_add, mock_warning): + """Prüft, dass ein Fehler beim Entfernen alter Docks nur gewarnt und das neue Dock trotzdem angezeigt wird.""" + dock = _FakeDock(name="sn_dock_x") + + DockManager.show(dock) + + mock_warning.assert_called_once() + mock_add.assert_called_once() + + @patch("sn_basis.ui.dockmanager.error") + @patch("sn_basis.ui.dockmanager.add_dock_widget", side_effect=RuntimeError("kaputt")) + @patch("sn_basis.ui.dockmanager.find_dock_widgets", return_value=[]) + def test_fehler_beim_anzeigen_wird_gemeldet(self, mock_find, mock_add, mock_error): + """Prüft, dass ein Fehler beim Anzeigen des Docks als Fehler gemeldet wird.""" + dock = _FakeDock(name="sn_dock_x") + + DockManager.show(dock) + + mock_error.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_landkreise.py b/tests/test_landkreise.py new file mode 100644 index 0000000..dcf798b --- /dev/null +++ b/tests/test_landkreise.py @@ -0,0 +1,28 @@ +"""Tests für sn_basis/functions/landkreise.py::_lade_landkreise (Issue #65).""" + +import unittest +from unittest.mock import patch + +from sn_basis.functions import landkreise + + +class LadeLandkreiseTests(unittest.TestCase): + def test_laedt_die_echte_landkreisliste_nicht_leer(self): + """Prüft, dass _lade_landkreise die echte landkreise.json als nicht-leere Liste von (Kennzahl, Name)-Tupeln liefert.""" + ergebnis = landkreise._lade_landkreise() + + self.assertTrue(ergebnis) + kennzahl, name = ergebnis[0] + self.assertIsInstance(kennzahl, str) + self.assertIsInstance(name, str) + + def test_fehlende_datei_liefert_leere_liste(self): + """Prüft, dass eine nicht lesbare Datenquelle eine leere Liste liefert, statt eine Ausnahme zu werfen.""" + with patch.object(landkreise, "_DATA_DATEI", "C:/nicht/vorhanden/landkreise.json"): + ergebnis = landkreise._lade_landkreise() + + self.assertEqual(ergebnis, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ly_existence_wrapper.py b/tests/test_ly_existence_wrapper.py new file mode 100644 index 0000000..a5e1399 --- /dev/null +++ b/tests/test_ly_existence_wrapper.py @@ -0,0 +1,56 @@ +"""Tests für sn_basis/functions/ly_existence_wrapper.py::layer_is_valid/layer_is_usable (Issue #65).""" + +import unittest + +from sn_basis.functions.ly_existence_wrapper import layer_is_usable, layer_is_valid + + +class _FakeLayer: + def __init__(self, valid: bool = True, wirft_fehler: bool = False) -> None: + self._valid = valid + self._wirft_fehler = wirft_fehler + + def isValid(self) -> bool: + if self._wirft_fehler: + raise RuntimeError("kaputt") + return self._valid + + +class LayerIsValidTests(unittest.TestCase): + def test_none_ist_nicht_gueltig(self): + """Prüft, dass layer_is_valid für None False liefert.""" + self.assertFalse(layer_is_valid(None)) + + def test_objekt_ohne_isvalid_ist_nicht_gueltig(self): + """Prüft, dass ein Objekt ohne isValid()-Methode als ungültig gilt.""" + self.assertFalse(layer_is_valid(object())) + + def test_gueltiger_layer_liefert_true(self): + """Prüft, dass ein Layer mit isValid()==True als gültig erkannt wird.""" + self.assertTrue(layer_is_valid(_FakeLayer(valid=True))) + + def test_ungueltiger_layer_liefert_false(self): + """Prüft, dass ein Layer mit isValid()==False als ungültig erkannt wird.""" + self.assertFalse(layer_is_valid(_FakeLayer(valid=False))) + + def test_ausnahme_bei_isvalid_liefert_false(self): + """Prüft, dass eine Ausnahme in isValid() abgefangen wird und False liefert.""" + self.assertFalse(layer_is_valid(_FakeLayer(wirft_fehler=True))) + + +class LayerIsUsableTests(unittest.TestCase): + def test_none_ist_nicht_nutzbar(self): + """Prüft, dass layer_is_usable für None False liefert.""" + self.assertFalse(layer_is_usable(None)) + + def test_gueltiger_layer_ist_nutzbar(self): + """Prüft, dass ein vorhandener, gültiger Layer als nutzbar gilt.""" + self.assertTrue(layer_is_usable(_FakeLayer(valid=True))) + + def test_ungueltiger_layer_ist_nicht_nutzbar(self): + """Prüft, dass ein vorhandener, aber ungültiger Layer als nicht nutzbar gilt.""" + self.assertFalse(layer_is_usable(_FakeLayer(valid=False))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ly_style_wrapper.py b/tests/test_ly_style_wrapper.py new file mode 100644 index 0000000..6346d84 --- /dev/null +++ b/tests/test_ly_style_wrapper.py @@ -0,0 +1,54 @@ +"""Tests für sn_basis/functions/ly_style_wrapper.py::apply_style_from_path (Issue #65). + +`apply_style` bleibt ausgeklammert: hardcodiert 'sn_verfahrensgebiet' in platformneutralem +sn_basis-Code (mutmaßlicher Bug, siehe docs/tests/testkatalog.md und Issue-Historie #63). +""" + +import unittest + +from sn_basis.functions.ly_style_wrapper import apply_style_from_path + + +class _FakeLayer: + def __init__(self, ergebnis=(True, ""), wirft_fehler: bool = False) -> None: + self._ergebnis = ergebnis + self._wirft_fehler = wirft_fehler + self.repaint_aufgerufen = False + + def loadNamedStyle(self, path: str): + if self._wirft_fehler: + raise RuntimeError("kaputt") + return self._ergebnis + + def triggerRepaint(self) -> None: + self.repaint_aufgerufen = True + + +class ApplyStyleFromPathTests(unittest.TestCase): + def test_fehlender_layer_liefert_false(self): + """Prüft, dass ein fehlender Layer (None) False liefert, ohne einen Stil zu laden.""" + self.assertFalse(apply_style_from_path(None, "stil.qml")) + + def test_erfolgreiches_laden_liefert_true_und_triggert_repaint(self): + """Prüft, dass ein erfolgreich geladener Stil True liefert und den Layer neu zeichnet.""" + layer = _FakeLayer(ergebnis=(True, "")) + + self.assertTrue(apply_style_from_path(layer, "stil.qml")) + self.assertTrue(layer.repaint_aufgerufen) + + def test_fehlgeschlagenes_laden_liefert_false_ohne_repaint(self): + """Prüft, dass ein fehlgeschlagenes Laden False liefert, ohne den Layer neu zu zeichnen.""" + layer = _FakeLayer(ergebnis=(False, "ungültiges QML")) + + self.assertFalse(apply_style_from_path(layer, "stil.qml")) + self.assertFalse(layer.repaint_aufgerufen) + + def test_ausnahme_beim_laden_wird_abgefangen(self): + """Prüft, dass eine Ausnahme beim Laden des Stils abgefangen wird und False liefert.""" + layer = _FakeLayer(wirft_fehler=True) + + self.assertFalse(apply_style_from_path(layer, "stil.qml")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_message_wrapper.py b/tests/test_message_wrapper.py new file mode 100644 index 0000000..00414d4 --- /dev/null +++ b/tests/test_message_wrapper.py @@ -0,0 +1,49 @@ +"""Tests für sn_basis/functions/message_wrapper.py (Issue #65).""" + +import unittest +from unittest.mock import patch + +from sn_basis.functions import message_wrapper + + +class GetMessageBarTests(unittest.TestCase): + def test_liefert_objekt_mit_pushmessage_methode(self): + """Prüft, dass _get_message_bar ein Objekt mit aufrufbarer pushMessage()-Methode liefert.""" + bar = message_wrapper._get_message_bar() + + self.assertTrue(callable(getattr(bar, "pushMessage", None))) + + def test_faellt_bei_fehlerhaftem_iface_auf_mock_bar_zurueck(self): + """Prüft, dass ein Fehler beim Zugriff auf iface.messageBar() auf eine funktionierende Mock-MessageBar zurückfällt.""" + class _KaputterIface: + def messageBar(self): + raise RuntimeError("keine Message-Bar verfügbar") + + with patch.object(message_wrapper, "iface", _KaputterIface()): + bar = message_wrapper._get_message_bar() + ergebnis = bar.pushMessage("Titel", "Text") + + self.assertEqual(ergebnis["title"], "Titel") + + +class PushMessageTests(unittest.TestCase): + def test_liefert_dict_mit_titel_text_level_und_dauer(self): + """Prüft, dass push_message im Mock-Modus ein Dict mit den übergebenen Werten liefert.""" + ergebnis = message_wrapper.push_message(2, "Titel", "Text", duration=7) + + self.assertEqual(ergebnis, {"title": "Titel", "text": "Text", "level": 2, "duration": 7}) + + def test_fehler_in_der_message_bar_wird_abgefangen(self): + """Prüft, dass eine fehlschlagende MessageBar push_message nicht zum Absturz bringt, sondern None liefert.""" + class _KaputteMessageBar: + def pushMessage(self, *args, **kwargs): + raise RuntimeError("kaputt") + + with patch.object(message_wrapper, "_get_message_bar", return_value=_KaputteMessageBar()): + ergebnis = message_wrapper.push_message(0, "Titel", "Text") + + self.assertIsNone(ergebnis) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pruef_ergebnis.py b/tests/test_pruef_ergebnis.py new file mode 100644 index 0000000..344cb6c --- /dev/null +++ b/tests/test_pruef_ergebnis.py @@ -0,0 +1,47 @@ +"""Tests für sn_basis/modules/pruef_ergebnis.py::ist_verfahrens_db_aktion/erlaubte_persistierung (Issue #65).""" + +import unittest + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +class IstVerfahrensDbAktionTests(unittest.TestCase): + def test_liefert_true_fuer_alle_verfahrens_db_aktionen(self): + """Prüft, dass ist_verfahrens_db_aktion für alle vier Verfahrens-DB-Aktionscodes True liefert.""" + for aktion in ( + "datei_wird_erzeugt", + "datei_existiert", + "datei_existiert_ueberschreiben", + "datei_existiert_anhaengen", + "datei_existiert_ueberspringen", + ): + with self.subTest(aktion=aktion): + ergebnis = pruef_ergebnis(ok=True, aktion=aktion) + self.assertTrue(ergebnis.ist_verfahrens_db_aktion) + + def test_liefert_false_fuer_andere_aktionen(self): + """Prüft, dass ist_verfahrens_db_aktion für Nicht-Verfahrens-DB-Aktionen und ohne Aktion False liefert.""" + for aktion in ("ok", "datenabruf", None): + with self.subTest(aktion=aktion): + ergebnis = pruef_ergebnis(ok=True, aktion=aktion) + self.assertFalse(ergebnis.ist_verfahrens_db_aktion) + + +class ErlaubtePersistierungTests(unittest.TestCase): + def test_liefert_true_fuer_erzeugen_ueberschreiben_anhaengen(self): + """Prüft, dass erlaubte_persistierung für Erzeugen/Überschreiben/Anhängen True liefert.""" + for aktion in ("datei_wird_erzeugt", "datei_existiert_ueberschreiben", "datei_existiert_anhaengen"): + with self.subTest(aktion=aktion): + ergebnis = pruef_ergebnis(ok=True, aktion=aktion) + self.assertTrue(ergebnis.erlaubte_persistierung) + + def test_liefert_false_fuer_ueberspringen_und_reines_existieren(self): + """Prüft, dass erlaubte_persistierung bei Überspringen und reinem 'datei_existiert' False liefert.""" + for aktion in ("datei_existiert_ueberspringen", "datei_existiert", "ok", None): + with self.subTest(aktion=aktion): + ergebnis = pruef_ergebnis(ok=True, aktion=aktion) + self.assertFalse(ergebnis.erlaubte_persistierung) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pruefmanager.py b/tests/test_pruefmanager.py index 3e542fc..ef87807 100644 --- a/tests/test_pruefmanager.py +++ b/tests/test_pruefmanager.py @@ -189,6 +189,111 @@ class TestPruefmanager(unittest.TestCase): self.assertFalse(entscheidung.ok) self.assertEqual(entscheidung.aktion, "datei_existiert") + # ----------------------------------------------------- + # 11. Verfahrensgebiet-Layer existiert bereits + # ----------------------------------------------------- + @patch("sn_basis.modules.Pruefmanager.ask_ergaenzen_entfernen_ersetzen_abbrechen", return_value="ergaenzen") + def test_verfahrensgebiet_layer_existiert_ergaenzen(self, _mock_choice): + """Prüft, dass die Nutzerwahl 'ergänzen' bei bestehendem Verfahrensgebiet zur Aktion 'verfahrensgebiet_ergaenzen' führt.""" + ergebnis = pruef_ergebnis(False, "VG existiert", "verfahrensgebiet_layer_existiert", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "verfahrensgebiet_ergaenzen") + + @patch("sn_basis.modules.Pruefmanager.ask_ergaenzen_entfernen_ersetzen_abbrechen", return_value="abbrechen") + def test_verfahrensgebiet_layer_existiert_abbrechen(self, _mock_choice): + """Prüft, dass die Nutzerwahl 'abbrechen' bei bestehendem Verfahrensgebiet ok=False mit Aktion 'verfahrensgebiet_abbrechen' liefert.""" + ergebnis = pruef_ergebnis(False, "VG existiert", "verfahrensgebiet_layer_existiert", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "verfahrensgebiet_abbrechen") + + def test_verfahrensgebiet_layer_existiert_in_non_qgis_mode_returns_input(self): + """Prüft, dass außerhalb des QGIS-Modus bei bestehendem Verfahrensgebiet keine Rückfrage erfolgt.""" + manager = Pruefmanager(ui_modus="test") + ergebnis = pruef_ergebnis(False, "VG existiert", "verfahrensgebiet_layer_existiert", None) + + entscheidung = manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "verfahrensgebiet_layer_existiert") + + # ----------------------------------------------------- + # 12. report_error: Einstufung nach Aktionscode + # ----------------------------------------------------- + @patch("sn_basis.modules.Pruefmanager.error") + @patch("sn_basis.modules.Pruefmanager.warning") + def test_report_error_kritische_aktion_ruft_error_auf(self, mock_warning, mock_error): + """Prüft, dass eine kritische Aktion (z. B. 'netzwerkfehler') über error() gemeldet wird, nicht über warning().""" + self.manager.report_error("Thema", "Meldung", aktion="netzwerkfehler") + + mock_error.assert_called_once_with("Thema", "Meldung") + mock_warning.assert_not_called() + + @patch("sn_basis.modules.Pruefmanager.error") + @patch("sn_basis.modules.Pruefmanager.warning") + def test_report_error_warn_aktion_ruft_warning_auf(self, mock_warning, mock_error): + """Prüft, dass eine Warn-Aktion (z. B. 'datei_nicht_gefunden') über warning() gemeldet wird, nicht über error().""" + self.manager.report_error("Thema", "Meldung", aktion="datei_nicht_gefunden") + + mock_warning.assert_called_once_with("Thema", "Meldung") + mock_error.assert_not_called() + + @patch("sn_basis.modules.Pruefmanager.error") + @patch("sn_basis.modules.Pruefmanager.warning") + def test_report_error_unbekannte_aktion_faellt_auf_warning_zurueck(self, mock_warning, mock_error): + """Prüft, dass eine nicht eingestufte Aktion als Fallback über warning() gemeldet wird.""" + self.manager.report_error("Thema", "Meldung", aktion="voellig_unbekannt") + + mock_warning.assert_called_once_with("Thema", "Meldung") + mock_error.assert_not_called() + + # ----------------------------------------------------- + # 13. frage_ja_nein + # ----------------------------------------------------- + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True) + def test_frage_ja_nein_im_qgis_modus_fragt_nutzer(self, mock_ask): + """Prüft, dass frage_ja_nein im QGIS-Modus den Nutzer fragt und dessen Antwort liefert.""" + ergebnis = self.manager.frage_ja_nein("Titel", "Meldung", default=False) + + self.assertTrue(ergebnis) + mock_ask.assert_called_once() + + @patch("sn_basis.modules.Pruefmanager.ask_yes_no") + def test_frage_ja_nein_ausserhalb_qgis_liefert_default_ohne_rueckfrage(self, mock_ask): + """Prüft, dass frage_ja_nein außerhalb des QGIS-Modus den Default-Wert liefert, ohne den Nutzer zu fragen.""" + manager = Pruefmanager(ui_modus="test") + + ergebnis = manager.frage_ja_nein("Titel", "Meldung", default=True) + + self.assertTrue(ergebnis) + mock_ask.assert_not_called() + + # ----------------------------------------------------- + # 14. frage_text + # ----------------------------------------------------- + @patch("sn_basis.functions.dialog_wrapper.ask_text", return_value=("Neuer Text", True)) + def test_frage_text_im_qgis_modus_fragt_nutzer(self, mock_ask): + """Prüft, dass frage_text im QGIS-Modus den Nutzer fragt und Text plus OK-Status liefert.""" + text, ok = self.manager.frage_text("Titel", "Meldung", default_text="Alt") + + self.assertEqual(text, "Neuer Text") + self.assertTrue(ok) + mock_ask.assert_called_once() + + @patch("sn_basis.functions.dialog_wrapper.ask_text") + def test_frage_text_ausserhalb_qgis_liefert_default_ohne_rueckfrage(self, mock_ask): + """Prüft, dass frage_text außerhalb des QGIS-Modus den Standardtext mit OK=True liefert, ohne den Nutzer zu fragen.""" + manager = Pruefmanager(ui_modus="test") + + text, ok = manager.frage_text("Titel", "Meldung", default_text="Standard") + + self.assertEqual(text, "Standard") + self.assertTrue(ok) + mock_ask.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_qgiscore_wrapper_helpers.py b/tests/test_qgiscore_wrapper_helpers.py new file mode 100644 index 0000000..16b95ed --- /dev/null +++ b/tests/test_qgiscore_wrapper_helpers.py @@ -0,0 +1,168 @@ +"""Tests für sn_basis/functions/qgiscore_wrapper.py::polygon_zu_linie/layer_exists_in_gpkg (Issue #65).""" + +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from sn_basis.functions import qgiscore_wrapper as qc + + +class _FakeGeomMitBoundary: + def __init__(self, boundary_ergebnis) -> None: + self._boundary_ergebnis = boundary_ergebnis + + def isEmpty(self) -> bool: + return False + + def boundary(self): + return self._boundary_ergebnis + + +class _FakeLinie: + def __init__(self, empty: bool = False) -> None: + self._empty = empty + + def isEmpty(self) -> bool: + return self._empty + + +class _FakeGeomOhneBoundary: + def __init__( + self, + multipart: bool = False, + polygon=None, + multipolygon=None, + aspolygon_fehler: bool = False, + convert_ergebnis=None, + convert_fehler: bool = False, + ) -> None: + self._multipart = multipart + self._polygon = polygon or [] + self._multipolygon = multipolygon or [] + self._aspolygon_fehler = aspolygon_fehler + self._convert_ergebnis = convert_ergebnis + self._convert_fehler = convert_fehler + + def isEmpty(self) -> bool: + return False + + def isMultipart(self) -> bool: + return self._multipart + + def asPolygon(self): + if self._aspolygon_fehler: + raise RuntimeError("kaputt") + return self._polygon + + def asMultiPolygon(self): + return self._multipolygon + + def convertToType(self, typ, dest_multipart): + if self._convert_fehler: + raise RuntimeError("kaputt") + return self._convert_ergebnis + + +class _FakeQgsGeometryKlasse: + @staticmethod + def fromPolylineXY(ring): + return ("linie", ring) + + @staticmethod + def unaryUnion(lines): + return ("union", list(lines)) + + +class PolygonZuLinieTests(unittest.TestCase): + def test_none_liefert_none(self): + """Prüft, dass polygon_zu_linie für None-Eingabe None liefert.""" + self.assertIsNone(qc.polygon_zu_linie(None)) + + def test_leere_geometrie_liefert_none(self): + """Prüft, dass eine leere Geometrie None liefert.""" + self.assertIsNone(qc.polygon_zu_linie(_FakeLinie(empty=True))) + + def test_stufe1_nutzt_boundary_wenn_vorhanden(self): + """Prüft, dass eine Geometrie mit boundary() (QGIS 3.x-Pfad) deren Ergebnis direkt liefert.""" + umring = _FakeLinie(empty=False) + geom = _FakeGeomMitBoundary(umring) + + self.assertIs(qc.polygon_zu_linie(geom), umring) + + def test_stufe2_extrahiert_ringe_bei_einfachem_polygon(self): + """Prüft, dass ohne boundary() die Ringe eines einfachen Polygons manuell extrahiert werden.""" + geom = _FakeGeomOhneBoundary(multipart=False, polygon=[["ring1"], ["ring2"]]) + + with patch.object(qc, "QgsGeometry", _FakeQgsGeometryKlasse): + ergebnis = qc.polygon_zu_linie(geom) + + self.assertEqual(ergebnis, ("union", [("linie", ["ring1"]), ("linie", ["ring2"])])) + + def test_stufe2_extrahiert_alle_ringe_bei_multipolygon(self): + """Prüft, dass bei einem MultiPolygon die Ringe aller Teilpolygone extrahiert werden.""" + geom = _FakeGeomOhneBoundary(multipart=True, multipolygon=[[["a"], ["b"]], [["c"]]]) + + with patch.object(qc, "QgsGeometry", _FakeQgsGeometryKlasse): + ergebnis = qc.polygon_zu_linie(geom) + + self.assertEqual(len(ergebnis[1]), 3) + + def test_stufe3_fallback_wenn_keine_ringe_extrahierbar(self): + """Prüft, dass bei fehlgeschlagener Ringextraktion auf convertToType zurückgefallen wird.""" + ergebnis_konvertiert = _FakeLinie(empty=False) + geom = _FakeGeomOhneBoundary(aspolygon_fehler=True, convert_ergebnis=ergebnis_konvertiert) + + with patch.object(qc, "QgsGeometry", _FakeQgsGeometryKlasse): + ergebnis = qc.polygon_zu_linie(geom) + + self.assertIs(ergebnis, ergebnis_konvertiert) + + def test_alle_stufen_scheitern_liefert_none(self): + """Prüft, dass polygon_zu_linie None liefert, wenn Ringextraktion und convertToType beide scheitern.""" + geom = _FakeGeomOhneBoundary(aspolygon_fehler=True, convert_fehler=True) + + with patch.object(qc, "QgsGeometry", _FakeQgsGeometryKlasse): + ergebnis = qc.polygon_zu_linie(geom) + + self.assertIsNone(ergebnis) + + +class LayerExistsInGpkgTests(unittest.TestCase): + def _erzeuge_gpkg(self, tmp_dir: str, mit_layer: str | None) -> str: + pfad = str(Path(tmp_dir) / "verfahren.gpkg") + conn = sqlite3.connect(pfad) + conn.execute("CREATE TABLE gpkg_contents (table_name TEXT)") + if mit_layer: + conn.execute("INSERT INTO gpkg_contents (table_name) VALUES (?)", (mit_layer,)) + conn.commit() + conn.close() + return pfad + + def test_fehlende_datei_liefert_false(self): + """Prüft, dass eine nicht vorhandene GPKG-Datei False liefert.""" + self.assertFalse(qc.layer_exists_in_gpkg("C:/nicht/vorhanden.gpkg", "Flurstuecke")) + + def test_leerer_pfad_oder_layername_liefert_false(self): + """Prüft, dass ein leerer Pfad oder Layername False liefert.""" + self.assertFalse(qc.layer_exists_in_gpkg("", "Flurstuecke")) + self.assertFalse(qc.layer_exists_in_gpkg("C:/tmp/x.gpkg", "")) + + def test_vorhandener_layer_liefert_true(self): + """Prüft, dass ein in gpkg_contents eingetragener Layername als vorhanden erkannt wird.""" + with tempfile.TemporaryDirectory() as tmp: + pfad = self._erzeuge_gpkg(tmp, mit_layer="Flurstuecke") + + self.assertTrue(qc.layer_exists_in_gpkg(pfad, "Flurstuecke")) + + def test_fehlender_layer_liefert_false(self): + """Prüft, dass ein nicht in gpkg_contents eingetragener Layername als nicht vorhanden gilt.""" + with tempfile.TemporaryDirectory() as tmp: + pfad = self._erzeuge_gpkg(tmp, mit_layer=None) + + self.assertFalse(qc.layer_exists_in_gpkg(pfad, "Flurstuecke")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qgisui_wrapper_helpers.py b/tests/test_qgisui_wrapper_helpers.py new file mode 100644 index 0000000..1a3fce7 --- /dev/null +++ b/tests/test_qgisui_wrapper_helpers.py @@ -0,0 +1,38 @@ +"""Tests für sn_basis/functions/qgisui_wrapper.py::add_layer_to_project (Issue #65).""" + +import unittest +from unittest.mock import MagicMock, patch + +from sn_basis.functions import qgisui_wrapper as qui + + +class AddLayerToProjectTests(unittest.TestCase): + def test_none_layer_liefert_false(self): + """Prüft, dass add_layer_to_project für einen fehlenden Layer (None) False liefert.""" + self.assertFalse(qui.add_layer_to_project(None)) + + def test_mock_modus_liefert_true_ohne_projektzugriff(self): + """Prüft, dass im Mock-Modus (ohne QGIS) ein gültiger Layer True liefert, ohne das Projekt anzufassen.""" + with patch.object(qui, "QGIS_AVAILABLE", False): + self.assertTrue(qui.add_layer_to_project(object())) + + def test_mit_qgis_fuegt_layer_dem_projekt_hinzu(self): + """Prüft, dass bei verfügbarem QGIS der Layer über QgsProject.instance().addMapLayer hinzugefügt wird.""" + layer = object() + mock_project = MagicMock() + with patch.object(qui, "QGIS_AVAILABLE", True), patch.object(qui, "QgsProject", mock_project): + ergebnis = qui.add_layer_to_project(layer) + + self.assertTrue(ergebnis) + mock_project.instance.return_value.addMapLayer.assert_called_once_with(layer) + + def test_mit_qgis_fehler_liefert_false(self): + """Prüft, dass ein Fehler beim Hinzufügen zum Projekt abgefangen wird und False liefert.""" + mock_project = MagicMock() + mock_project.instance.side_effect = RuntimeError("kaputt") + with patch.object(qui, "QGIS_AVAILABLE", True), patch.object(qui, "QgsProject", mock_project): + self.assertFalse(qui.add_layer_to_project(object())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_tab.py b/tests/test_settings_tab.py new file mode 100644 index 0000000..c3d1b8c --- /dev/null +++ b/tests/test_settings_tab.py @@ -0,0 +1,117 @@ +"""Tests für sn_basis/ui/tabs/settings_tab.py::save_data/load_data (Issue #65).""" + +import unittest + +from sn_basis.functions.qt_wrapper import QComboBox, QLineEdit +from sn_basis.ui.tabs.settings_tab import SettingsTab + + +class _FakeLogic: + def __init__(self, daten=None) -> None: + self.gespeichert = None + self._daten = daten or {} + + def save(self, fields) -> None: + self.gespeichert = fields + + def load(self) -> dict: + return self._daten + + +def _neuer_tab(logic, user_inputs=None, project_inputs=None) -> SettingsTab: + """Erzeugt einen SettingsTab ohne den echten __init__ (der Qt-Widgets aufbaut).""" + tab = SettingsTab.__new__(SettingsTab) + tab.logic = logic + tab.user_inputs = user_inputs or {} + tab.project_inputs = project_inputs or {} + return tab + + +def _combo_mit_landkreisen() -> QComboBox: + combo = QComboBox() + combo.addItem("", None) + combo.addItem("Landkreis A", "01") + combo.addItem("Landkreis B", "02") + return combo + + +class SaveDataTests(unittest.TestCase): + def test_sammelt_text_und_combobox_werte_inklusive_landkreiskennzahl(self): + """Prüft, dass save_data Text- und Dropdown-Werte sammelt und die Landkreiskennzahl separat ergänzt.""" + amt_feld = QLineEdit() + amt_feld.setText("Meißen") + landkreis_combo = _combo_mit_landkreisen() + landkreis_combo.setCurrentIndex(1) # "Landkreis A" / "01" + name_feld = QLineEdit() + name_feld.setText("Verfahren X") + + logic = _FakeLogic() + tab = _neuer_tab( + logic, + user_inputs={"amt": amt_feld, "landkreis_user": landkreis_combo}, + project_inputs={"name": name_feld}, + ) + + tab.save_data() + + self.assertEqual( + logic.gespeichert, + {"amt": "Meißen", "landkreis_user": "Landkreis A", "name": "Verfahren X", "landkreis_kennzahl": "01"}, + ) + + def test_ohne_landkreis_dropdown_wird_keine_kennzahl_ergaenzt(self): + """Prüft, dass ohne Landkreis-Dropdown im Formular keine 'landkreis_kennzahl' ergänzt wird.""" + logic = _FakeLogic() + tab = _neuer_tab(logic, user_inputs={"amt": QLineEdit()}, project_inputs={}) + + tab.save_data() + + self.assertNotIn("landkreis_kennzahl", logic.gespeichert) + + +class LoadDataTests(unittest.TestCase): + def test_befuellt_textfelder_aus_gespeicherten_daten(self): + """Prüft, dass load_data ein Textfeld mit dem gespeicherten Wert befüllt.""" + amt_feld = QLineEdit() + tab = _neuer_tab(_FakeLogic({"amt": "Dresden"}), user_inputs={"amt": amt_feld}, project_inputs={}) + + tab.load_data() + + self.assertEqual(amt_feld.text(), "Dresden") + + def test_ohne_gespeicherten_wert_bleibt_textfeld_leer(self): + """Prüft, dass load_data ohne gespeicherten Wert das Textfeld auf einen leeren Text setzt.""" + amt_feld = QLineEdit() + tab = _neuer_tab(_FakeLogic({}), user_inputs={"amt": amt_feld}, project_inputs={}) + + tab.load_data() + + self.assertEqual(amt_feld.text(), "") + + def test_waehlt_combobox_eintrag_bei_bekanntem_namen(self): + """Prüft, dass load_data im Landkreis-Dropdown den gespeicherten Landkreisnamen auswählt.""" + combo = _combo_mit_landkreisen() + tab = _neuer_tab( + _FakeLogic({"landkreis_user": "Landkreis B"}), user_inputs={"landkreis_user": combo}, project_inputs={} + ) + + tab.load_data() + + self.assertEqual(combo.currentText(), "Landkreis B") + + def test_unbekannter_gespeicherter_name_waehlt_ersten_eintrag(self): + """Prüft, dass load_data bei einem nicht mehr vorhandenen Landkreisnamen auf den ersten Eintrag zurückfällt.""" + combo = _combo_mit_landkreisen() + tab = _neuer_tab( + _FakeLogic({"landkreis_user": "Nicht mehr vorhanden"}), + user_inputs={"landkreis_user": combo}, + project_inputs={}, + ) + + tab.load_data() + + self.assertEqual(combo.currentText(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sys_wrapper.py b/tests/test_sys_wrapper.py index 3edd962..ba400f5 100644 --- a/tests/test_sys_wrapper.py +++ b/tests/test_sys_wrapper.py @@ -52,5 +52,47 @@ class TestGetPluginRoot(unittest.TestCase): self.assertEqual(ergebnis, verlinkter_ordner) +class JoinPathTests(unittest.TestCase): + def test_verbindet_mehrere_pfadbestandteile(self): + """Prüft, dass join_path mehrere Bestandteile OS-sicher zu einem Path zusammenfügt.""" + ergebnis = sys_wrapper.join_path("a", "b", "c.txt") + + self.assertEqual(ergebnis, Path("a") / "b" / "c.txt") + + +class EnsureDirTests(unittest.TestCase): + def test_legt_fehlendes_verzeichnis_inklusive_eltern_an(self): + """Prüft, dass ensure_dir ein fehlendes Verzeichnis samt Elternverzeichnissen anlegt.""" + with TemporaryDirectory() as tmp: + ziel = Path(tmp) / "a" / "b" / "c" + + ergebnis = sys_wrapper.ensure_dir(ziel) + + self.assertTrue(ziel.is_dir()) + self.assertEqual(ergebnis, ziel) + + def test_vorhandenes_verzeichnis_wirft_keinen_fehler(self): + """Prüft, dass ensure_dir bei einem bereits vorhandenen Verzeichnis keinen Fehler wirft.""" + with TemporaryDirectory() as tmp: + sys_wrapper.ensure_dir(Path(tmp)) # darf nicht scheitern + + +class GetattrSafeTests(unittest.TestCase): + def test_liefert_vorhandenes_attribut(self): + """Prüft, dass getattr_safe ein vorhandenes Attribut liefert.""" + class _Beispiel: + wert = 42 + + self.assertEqual(sys_wrapper.getattr_safe(_Beispiel(), "wert"), 42) + + def test_liefert_default_bei_fehlendem_attribut(self): + """Prüft, dass getattr_safe bei fehlendem Attribut den Default-Wert liefert.""" + class _Beispiel: + pass + + self.assertEqual(sys_wrapper.getattr_safe(_Beispiel(), "fehlt", "ersatz"), "ersatz") + self.assertIsNone(sys_wrapper.getattr_safe(_Beispiel(), "fehlt")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_verfahrensgebiet_manager.py b/tests/test_verfahrensgebiet_manager.py index 44b4dec..aad6d9b 100644 --- a/tests/test_verfahrensgebiet_manager.py +++ b/tests/test_verfahrensgebiet_manager.py @@ -1,7 +1,10 @@ +"""Tests für sn_basis/functions/verfahrensgebiet_manager.py (zentrale Layer-Verfolgung, Issue #65).""" + import unittest from unittest.mock import patch from sn_basis.functions import verfahrensgebiet_manager +import sn_basis.functions.verfahrensgebiet_manager as vgm class _Scope: @@ -69,5 +72,127 @@ class TestVerfahrensgebietManager(unittest.TestCase): mock_set_variable.assert_called_once_with("verfahrensgebiet_layer", "layer_99", scope="project") +class _FakeSignal: + def __init__(self) -> None: + self.connected: list = [] + + def connect(self, fn) -> None: + self.connected.append(fn) + + def disconnect(self, fn) -> None: + self.connected.remove(fn) + + +class _FakeLayer: + def __init__(self, layer_id: str, name: str = "Verfahrensgebiet") -> None: + self._id = layer_id + self._name = name + + def id(self) -> str: + return self._id + + +class _FakeProject: + def __init__(self, layers=None) -> None: + self.layersAdded = _FakeSignal() + self.layerRemoved = _FakeSignal() + self.readProject = _FakeSignal() + self.newProjectCreated = _FakeSignal() + self.cleared = _FakeSignal() + self._layers = layers or [] + + def mapLayersByName(self, name: str): + return [l for l in self._layers if l._name == name] + + +def _mit_projekt(projekt: _FakeProject): + return patch.object(vgm, "QgsProject", type("_P", (), {"instance": staticmethod(lambda: projekt)})) + + +class OnHandlerTests(unittest.TestCase): + def setUp(self) -> None: + patch.object(vgm, "QGIS_AVAILABLE", True).start() + self.addCleanup(patch.stopall) + vgm.set_variable("verfahrensgebiet_layer", "", scope="project") + + def test_on_project_changed_ohne_layer_setzt_variable_leer(self): + """Prüft, dass _on_project_changed die Variable leert, wenn kein Verfahrensgebiet-Layer existiert.""" + vgm.set_variable("verfahrensgebiet_layer", "alter_layer", scope="project") + with _mit_projekt(_FakeProject(layers=[])): + vgm._on_project_changed() + + self.assertIsNone(vgm.get_verfahrensgebiet_layer_id()) + + def test_on_project_changed_mit_layer_setzt_variable(self): + """Prüft, dass _on_project_changed die Layer-ID eines gefundenen Verfahrensgebiet-Layers übernimmt.""" + with _mit_projekt(_FakeProject(layers=[_FakeLayer("layer123")])): + vgm._on_project_changed() + + self.assertEqual(vgm.get_verfahrensgebiet_layer_id(), "layer123") + + def test_on_layers_added_findet_neuen_layer(self): + """Prüft, dass _on_layers_added nach neu hinzugefügten Layern sucht und die Variable aktualisiert.""" + with _mit_projekt(_FakeProject(layers=[_FakeLayer("layer456")])): + vgm._on_layers_added(["irrelevant"]) + + self.assertEqual(vgm.get_verfahrensgebiet_layer_id(), "layer456") + + @patch.object(vgm, "_update_verfahrensgebiet_from_project") + def test_on_layer_removed_bei_aktuellem_layer_sucht_neu(self, mock_update): + """Prüft, dass das Entfernen des aktuell aktiven Verfahrensgebiet-Layers eine Neusuche auslöst.""" + vgm.set_variable("verfahrensgebiet_layer", "layer123", scope="project") + + vgm._on_layer_removed("layer123") + + mock_update.assert_called_once() + + @patch.object(vgm, "_update_verfahrensgebiet_from_project") + def test_on_layer_removed_bei_anderem_layer_aendert_nichts(self, mock_update): + """Prüft, dass das Entfernen eines anderen Layers keine Neusuche auslöst.""" + vgm.set_variable("verfahrensgebiet_layer", "layer123", scope="project") + + vgm._on_layer_removed("ganz_anderer_layer") + + mock_update.assert_not_called() + + +class ListenerSetupTests(unittest.TestCase): + def setUp(self) -> None: + patch.object(vgm, "_listener_installed", False).start() + self.addCleanup(patch.stopall) + + def test_setup_ohne_qgis_tut_nichts(self): + """Prüft, dass setup_verfahrensgebiet_listener ohne QGIS keine Signale registriert.""" + with patch.object(vgm, "QGIS_AVAILABLE", False): + vgm.setup_verfahrensgebiet_listener() + + self.assertFalse(vgm._listener_installed) + + def test_setup_verbindet_signale_und_ist_idempotent(self): + """Prüft, dass setup_verfahrensgebiet_listener die Projektsignale verbindet und ein zweiter Aufruf nichts doppelt registriert.""" + projekt = _FakeProject(layers=[]) + with patch.object(vgm, "QGIS_AVAILABLE", True), _mit_projekt(projekt): + vgm.setup_verfahrensgebiet_listener() + vgm.setup_verfahrensgebiet_listener() + + self.assertEqual(len(projekt.layersAdded.connected), 1) + self.assertEqual(len(projekt.layerRemoved.connected), 1) + self.assertEqual(len(projekt.readProject.connected), 1) + self.assertTrue(vgm._listener_installed) + + def test_teardown_trennt_alle_signale_und_ist_idempotent(self): + """Prüft, dass teardown_verfahrensgebiet_listener alle Signale trennt und mehrfacher Aufruf nicht fehlschlägt.""" + projekt = _FakeProject(layers=[]) + with patch.object(vgm, "QGIS_AVAILABLE", True), _mit_projekt(projekt): + vgm.setup_verfahrensgebiet_listener() + vgm.teardown_verfahrensgebiet_listener() + vgm.teardown_verfahrensgebiet_listener() + + self.assertEqual(projekt.layersAdded.connected, []) + self.assertEqual(projekt.layerRemoved.connected, []) + self.assertEqual(projekt.readProject.connected, []) + self.assertFalse(vgm._listener_installed) + + if __name__ == "__main__": unittest.main() -- 2.54.0