From 18573cb3be981176e919bfe90320b91d44597f60 Mon Sep 17 00:00:00 2001 From: release-bot Date: Fri, 10 Apr 2026 09:32:49 +0000 Subject: [PATCH 1/3] Release 26.4.2-testing --- changelog.txt | 2 ++ metadata.txt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.txt b/changelog.txt index 6fee27b..84adbba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.2-testing: +--- Version 26.04.1-unstable: --- Version 26.3.3-unstable: diff --git a/metadata.txt b/metadata.txt index c9ed787..27ee3e7 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,11 +1,11 @@ [general] -version=26.04.1-unstable +version=26.4.2-testing name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41 tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41/issues -repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_Test_Action/src/branch/unstable/ +repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_Test_Action/src/branch/testing/ qgisMinimumVersion=3.40 experimental=true From 8f9cf4c9fba110aba3e7b76ad6836843e8a6c160 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 14:31:49 +0200 Subject: [PATCH 2/3] =?UTF-8?q?pdoc-dockstrings=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 14 ++++++++++++++ main.py | 7 ++++++- modules/listenauswerter.py | 5 ++++- tests/__init__.py | 4 +++- ui/dockwidget.py | 21 ++++++++++++++++++++- ui/tabs/tab_b.py | 15 +++++++++++++++ 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 40ff784..2a5bea3 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,17 @@ +"""sn_plan41 – QGIS-Plugin für thematische Linklisten und Datenquellen. +Verwaltet Excel-basierte Link-Listen und Fachdatenabruf. +""" + + def classFactory(iface): + """Factory-Funktion für QGIS. + Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen. + + Args: + iface: QGIS-Schnittstellenobjekt + + Returns: + Plan41: Instanziiertes Plugin + """ from .main import Plan41 return Plan41(iface) diff --git a/main.py b/main.py index 87ab81e..fd8673f 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ -# sn_plan41/main.py +"""sn_plan41/main.py – Einstiegspunkt des sn_plan41 Plugins. +Orchestriert die UI und den Datenabruf für Thematische Linklisten. +""" from qgis.utils import plugins from sn_basis.ui.dockmanager import DockManager @@ -8,6 +10,9 @@ from sn_basis.modules.Pruefmanager import Pruefmanager class Plan41: + """Einstiegspunkt des sn_plan41-Plugins. + Verwaltet die DockWidget-Initialisierung und den Datenflusss. + """ def __init__(self, iface): self.iface = iface self.pruefmanager=Pruefmanager(ui_modus="qgis") diff --git a/modules/listenauswerter.py b/modules/listenauswerter.py index 4602aa5..86cd495 100644 --- a/modules/listenauswerter.py +++ b/modules/listenauswerter.py @@ -1,4 +1,7 @@ -#sn_plan41/modules/listenauswerter.py +"""sn_plan41/modules/listenauswerter.py – Validierung von Linklisten. +Prüft Zeilen aus DataDict auf Vollständigkeit und Konsistenz. +""" + from typing import Any, Dict, List, Mapping, Optional, Tuple from collections.abc import Mapping as _Mapping # Prüfer-Typen (werden als Instanzen erwartet) diff --git a/tests/__init__.py b/tests/__init__.py index 324c4b2..f0f6299 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,3 @@ -#Testordner \ No newline at end of file +"""sn_plan41/tests – Test-Suite für das sn_plan41 Plugin. +Enthält Unit-Tests und Integrationstests für Tab-Logik und Datengrabber. +""" diff --git a/ui/dockwidget.py b/ui/dockwidget.py index 51388b3..cfafb2c 100644 --- a/ui/dockwidget.py +++ b/ui/dockwidget.py @@ -1,4 +1,7 @@ -#sn_plan41/ui/dockwidget.py +"""sn_plan41/ui/dockwidget.py – Haupt-DockWidget für sn_plan41. +Verwaltet die Tabs für Linkvalidierung, Druck und Einstellungen. +""" + from sn_basis.ui.tabs.settings_tab import SettingsTab from sn_plan41.ui.tab_a_ui import TabA from sn_basis.ui.tabs.print_tab import PrintTab @@ -8,9 +11,25 @@ from sn_basis.functions.message_wrapper import error class DockWidget(BaseDockWidget): + """Haupt-DockWidget für sn_plan41 mit Tab-Verwaltung. + + Attributes: + tabs: Liste von Tab-Klassen (TabA, PrintTab, SettingsTab) + pruefmanager: Prüfmanager-Instanz für Validierung + data_grabber: DataGrabber-Instanz für Fachdatenabruf + """ + tabs = [TabA, PrintTab, SettingsTab] def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None): + """Initialisiert das DockWidget mit Services und Tabs. + + Args: + parent: Übergeordnetes Widget + subtitle: Untertitel des DockWidgets + pruefmanager: Prüfmanager-Instanz + data_grabber: DataGrabber-Instanz + """ super().__init__(parent, subtitle) # Services als Attribute speichern diff --git a/ui/tabs/tab_b.py b/ui/tabs/tab_b.py index 785b79e..97d115c 100644 --- a/ui/tabs/tab_b.py +++ b/ui/tabs/tab_b.py @@ -1,9 +1,24 @@ +"""sn_plan41/ui/tabs/tab_b.py – Beispiel-Tab für zusätzliche Funktionen. +Ein Tab für zukünftige Erweiterungen. +""" + from qgis.PyQt.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit class TabB(QWidget): + """Beispiel-Tab für sn_plan41. + + Attributes: + tab_title: Anzeigetitel des Tabs ("Tab B") + """ + tab_title = "Tab B" def __init__(self, parent=None): + """Initialisiert den Tab mit Beispielinhalt. + + Args: + parent: Übergeordnetes Widget + """ super().__init__(parent) layout = QVBoxLayout() layout.addWidget(QLabel("Plugin2 – Tab B")) From 600a3c7aff5db9f417887a95225d62fba1b48c34 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 15:36:29 +0200 Subject: [PATCH 3/3] =?UTF-8?q?test=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_listenauswerter.py | 84 ++++++++++++++++++ tests/test_tab_a_logic.py | 156 ++++++++++++++++++---------------- tests/test_tab_a_ui.py | 131 ++++++++++++++++++++-------- 3 files changed, 261 insertions(+), 110 deletions(-) create mode 100644 tests/test_listenauswerter.py diff --git a/tests/test_listenauswerter.py b/tests/test_listenauswerter.py new file mode 100644 index 0000000..1eadfcd --- /dev/null +++ b/tests/test_listenauswerter.py @@ -0,0 +1,84 @@ +import unittest +from typing import Any + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_plan41.modules.listenauswerter import Listenauswerter + + +class _DummyPruefmanager: + def __init__(self) -> None: + self.seen = [] + + def verarbeite(self, ergebnis: Any): + self.seen.append(ergebnis) + return ergebnis + + +class _DummyStilPruefer: + def __init__(self, ok: bool = True) -> None: + self.ok = ok + + def pruefe(self, _path: str) -> pruef_ergebnis: + if self.ok: + return pruef_ergebnis(ok=True, meldung="ok", aktion="ok", kontext=None) + return pruef_ergebnis(ok=False, meldung="stil fehler", aktion="falsche_endung", kontext=None) + + +class TestListenauswerter(unittest.TestCase): + + def test_validate_rows_accepts_valid_row_and_normalizes_provider(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "A1", "Inhalt": "Test", "Link": "http://x", "Provider": "wfs"}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertEqual(validated["rows"][0]["Provider"], "WFS") + self.assertEqual(processed, []) + + def test_validate_rows_rejects_missing_required_fields(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "", "Link": "", "Provider": ""}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(validated["rows"], []) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "pflichtfelder_fehlen") + + def test_validate_rows_sets_stildatei_none_on_style_failure(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=False)) + + data = { + "rows": [ + { + "ident": "A1", + "Inhalt": "Thema", + "Link": "http://x", + "Provider": "WMS", + "Stildatei": "broken.qml", + } + ] + } + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertIsNone(validated["rows"][0]["stildatei"]) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "falsche_endung") + + def test_validate_rows_handles_non_mapping_rows_edge_case(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + validated, processed = validator.validate_rows({"rows": ["invalid", 123, None]}) + + self.assertEqual(validated, {"rows": []}) + self.assertEqual(processed, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tab_a_logic.py b/tests/test_tab_a_logic.py index 94c3bd8..595d4d0 100644 --- a/tests/test_tab_a_logic.py +++ b/tests/test_tab_a_logic.py @@ -1,99 +1,109 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from typing import Any, cast +from unittest.mock import patch -from sn_plan41.ui.tab_a_logic import TabALogic # type: ignore -from sn_basis.functions.variable_wrapper import get_variable # type: ignore -from sn_basis.functions.sys_wrapper import file_exists # type: ignore +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_logic import TabALogic + + +class _DummyPruefmanager: + def verarbeite(self, ergebnis): + return ergebnis class TestTabALogic(unittest.TestCase): - # ----------------------------------------------------- - # 1. Verfahrens-DB setzen und laden - # ----------------------------------------------------- - def test_verfahrens_db_set_and_load(self): - logic = TabALogic() + def _make_logic(self) -> TabALogic: + return TabALogic( + pruefmanager=cast(Any, _DummyPruefmanager()), + link_pruefer=cast(Any, object()), + stil_pruefer=cast(Any, object()), + ) - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "test.gpkg" - db_path.write_text("") + def test_verfahrens_db_set_and_load_roundtrip(self): + logic = self._make_logic() + logic.set_verfahrens_db("C:/tmp/v_db.gpkg") + self.assertEqual(logic.load_verfahrens_db(), "C:/tmp/v_db.gpkg") - logic.set_verfahrens_db(str(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - loaded = logic.load_verfahrens_db() - self.assertEqual(loaded, str(db_path)) - - # ----------------------------------------------------- - # 2. Verfahrens-DB löschen - # ----------------------------------------------------- def test_verfahrens_db_clear(self): - logic = TabALogic() - + logic = self._make_logic() logic.set_verfahrens_db(None) + self.assertEqual(get_variable("verfahrens_db", scope="project"), "") - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, "") - - # ----------------------------------------------------- - # 3. Neue Verfahrens-DB anlegen - # ----------------------------------------------------- - def test_create_new_verfahrens_db(self): - logic = TabALogic() - - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "neu.gpkg" - - result = logic.create_new_verfahrens_db(str(db_path)) - - self.assertTrue(result) - self.assertTrue(file_exists(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - def test_create_new_verfahrens_db_with_none_path(self): - logic = TabALogic() - result = logic.create_new_verfahrens_db(None) - self.assertFalse(result) - - - - # ----------------------------------------------------- - # 4. Linkliste setzen und laden - # ----------------------------------------------------- - def test_linkliste_set_and_load(self): - logic = TabALogic() - + def test_linkliste_set_and_load_existing_path(self): + logic = self._make_logic() with TemporaryDirectory() as tmp: link_path = Path(tmp) / "links.xlsx" - link_path.write_text("dummy") + link_path.write_text("x", encoding="utf-8") logic.set_linkliste(str(link_path)) + self.assertEqual(logic.load_linkliste(), str(link_path)) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, str(link_path)) + def test_linkliste_load_returns_none_when_file_missing(self): + logic = self._make_logic() + logic.set_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(logic.load_linkliste()) - loaded = logic.load_linkliste() - self.assertEqual(loaded, str(link_path)) + @patch("sn_plan41.ui.tab_a_logic.get_plugin_root", return_value="C:/plugins") + @patch("sn_plan41.ui.tab_a_logic.join_path", return_value="C:/plugins/sn_plan41/assets/Linkliste.xlsx") + def test_resolve_linkliste_empty_uses_standard_and_persists(self, _mock_join_path, _mock_root): + logic = self._make_logic() + resolved = logic._resolve_linkliste(None) - # ----------------------------------------------------- - # 5. Linkliste löschen - # ----------------------------------------------------- - def test_linkliste_clear(self): - logic = TabALogic() + self.assertEqual(resolved, "C:/plugins/sn_plan41/assets/Linkliste.xlsx") + self.assertEqual(get_variable("linkliste", scope="project"), "C:/plugins/sn_plan41/assets/Linkliste.xlsx") - logic.set_linkliste(None) + @patch("sn_plan41.ui.tab_a_logic.Dateipruefer") + def test_resolve_linkliste_returns_none_when_pruefmanager_rejects(self, mock_dateipruefer): + logic = self._make_logic() + mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis( + ok=False, + meldung="Ungültig", + aktion="datei_nicht_gefunden", + kontext=None, + ) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, "") + resolved = logic._resolve_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(resolved) - # ----------------------------------------------------- - # 6. Layer-ID speichern - # ----------------------------------------------------- + def test_resolve_raumfilter_without_filter_returns_none(self): + logic = self._make_logic() + result = logic._resolve_raumfilter("ohne", "C:/tmp/v_db.gpkg") + self.assertIsNone(result) + + @patch.object(TabALogic, "_get_verfahrensgebiet_layer", return_value="layer_obj") + def test_resolve_raumfilter_verfahrensgebiet_uses_layer_lookup(self, mock_get_layer): + logic = self._make_logic() + result = logic._resolve_raumfilter("Verfahrensgebiet", "C:/tmp/v_db.gpkg") + + self.assertEqual(result, "layer_obj") + mock_get_layer.assert_called_once() + + def test_extract_datenabruf_error_maps_merges_reason_maps(self): + logic = self._make_logic() + results = [ + pruef_ergebnis( + ok=False, + meldung="Head failed", + aktion="url_nicht_erreichbar", + kontext={"ident": "A1", "thema": "Dienst A", "error": "Timeout"}, + ), + pruef_ergebnis( + ok=False, + meldung="Fetch failed", + aktion="datenabruf", + kontext={"fehler": {"Dienst B": "HTTP 500", "Dienst A": "ignored"}}, + ), + ] + + merged = logic._extract_datenabruf_error_maps(results) + + self.assertEqual(merged["by_ident"]["A1"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst A"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst B"], "HTTP 500") if __name__ == "__main__": diff --git a/tests/test_tab_a_ui.py b/tests/test_tab_a_ui.py index 4911949..1fa7022 100644 --- a/tests/test_tab_a_ui.py +++ b/tests/test_tab_a_ui.py @@ -1,56 +1,113 @@ -""" -Smoke-Tests für TabA UI (sn_plan41/ui/tab_a_ui.py) - -Ziel: -- UI kann erstellt werden -- Callbacks crashen nicht -- Keine Qt-Verhaltensprüfung -""" - import unittest +from unittest.mock import patch -from sn_plan41.ui.tab_a_ui import TabA #type:ignore +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_ui import TabA + + +class _DummySignal: + def connect(self, _callback): + return None + + +class _DummyToggleButton: + def __init__(self): + self.arrow = None + self.style = "" + + def setArrowType(self, arrow): + self.arrow = arrow + + def setStyleSheet(self, style): + self.style = style + + +class _DummyWidget: + def __init__(self): + self.visible = None + + def setVisible(self, visible): + self.visible = visible + + +class _DummyFileWidget: + def __init__(self): + self._path = "" + + def setFilePath(self, path): + self._path = path + + def filePath(self): + return self._path + + +class _DummyCombo: + def __init__(self, value="Pufferlayer"): + self._value = value + + def currentText(self): + return self._value + + +class _DummyPruefmanager: + pass + + +class _DummyDataGrabber: + pass class TestTabAUI(unittest.TestCase): - # ----------------------------------------------------- - # 1. UI kann erstellt werden - # ----------------------------------------------------- - def test_tab_a_ui_can_be_created(self): - tab = TabA(parent=None,build_ui=False) + def _make_tab_without_qt(self): + tab = TabA.__new__(TabA) + tab.group_button = _DummyToggleButton() + tab.group_content = _DummyWidget() + tab.optional_button = _DummyToggleButton() + tab.optional_content = _DummyWidget() + tab.file_widget = _DummyFileWidget() + tab.linkliste_widget = _DummyFileWidget() + tab._raumfilter_combo = _DummyCombo("ohne") + tab.verfahrens_db = None + tab.lokale_linkliste = None + tab.logic = None + tab.pruefmanager = None + tab.data_grabber = None + return tab - self.assertIsNotNone(tab) - self.assertEqual(tab.tab_title, "Daten") - - # ----------------------------------------------------- - # 2. Toggle-Callbacks crashen nicht - # ----------------------------------------------------- - def test_tab_a_toggle_callbacks_do_not_crash(self): - tab = TabA(parent=None,build_ui=False) + def test_tab_a_callbacks_can_be_called_without_crash(self): + tab = self._make_tab_without_qt() tab._toggle_group(True) tab._toggle_group(False) - tab._toggle_optional(True) tab._toggle_optional(False) - - # ----------------------------------------------------- - # 3. Datei-Callbacks akzeptieren leere Eingaben - # ----------------------------------------------------- - def test_tab_a_file_callbacks_accept_empty_input(self): - tab = TabA(parent=None,build_ui=False) - tab._on_verfahrens_db_changed("") tab._on_linkliste_changed("") - # ----------------------------------------------------- - # 4. Layer-Callback akzeptiert None - # ----------------------------------------------------- - def test_tab_a_layer_callback_accepts_none(self): - tab = TabA(parent=None,build_ui=False) + self.assertEqual(tab.group_content.visible, False) + self.assertEqual(tab.optional_content.visible, False) - tab._on_layer_changed(None) + def test_tab_a_raumfilter_callback_persists_value(self): + tab = self._make_tab_without_qt() + + tab._on_raumfilter_changed("ohne") + self.assertEqual(get_variable("Raumfilter", scope="project"), "ohne") + + @patch("sn_plan41.ui.tab_a_ui.TabALogic") + def test_set_services_initializes_logic_and_injects_data_grabber(self, mock_logic_cls): + tab = self._make_tab_without_qt() + mock_logic = mock_logic_cls.return_value + + pm = _DummyPruefmanager() + dg = _DummyDataGrabber() + + tab.set_services(pruefmanager=pm, data_grabber=dg) + + self.assertIs(tab.pruefmanager, pm) + self.assertIs(tab.data_grabber, dg) + self.assertIs(tab.logic, mock_logic) + self.assertIs(tab.logic.data_grabber, dg) if __name__ == "__main__":