Merge pull request 'test überarbeitet' (#24) from feature/Tests_erweitert into unstable
Release Plugin / release (push) Successful in 3s

Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
2026-04-22 09:22:49 +02:00
10 changed files with 324 additions and 115 deletions
+14
View File
@@ -1,3 +1,17 @@
"""sn_plan41 – QGIS-Plugin für thematische Linklisten und Datenquellen.
Verwaltet Excel-basierte Link-Listen und Fachdatenabruf.
"""
def classFactory(iface): 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 from .main import Plan41
return Plan41(iface) return Plan41(iface)
+1 -1
View File
@@ -1,5 +1,5 @@
--- ---
Version 26.4.2-unstable: Version 26.4.2-testing:
--- ---
Version 26.04.1-unstable: Version 26.04.1-unstable:
--- ---
+6 -1
View File
@@ -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 qgis.utils import plugins
from sn_basis.ui.dockmanager import DockManager from sn_basis.ui.dockmanager import DockManager
@@ -8,6 +10,9 @@ from sn_basis.modules.Pruefmanager import Pruefmanager
class Plan41: class Plan41:
"""Einstiegspunkt des sn_plan41-Plugins.
Verwaltet die DockWidget-Initialisierung und den Datenflusss.
"""
def __init__(self, iface): def __init__(self, iface):
self.iface = iface self.iface = iface
self.pruefmanager=Pruefmanager(ui_modus="qgis") self.pruefmanager=Pruefmanager(ui_modus="qgis")
+4 -1
View File
@@ -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 typing import Any, Dict, List, Mapping, Optional, Tuple
from collections.abc import Mapping as _Mapping from collections.abc import Mapping as _Mapping
# Prüfer-Typen (werden als Instanzen erwartet) # Prüfer-Typen (werden als Instanzen erwartet)
+3 -1
View File
@@ -1 +1,3 @@
#Testordner """sn_plan41/tests – Test-Suite für das sn_plan41 Plugin.
Enthält Unit-Tests und Integrationstests für Tab-Logik und Datengrabber.
"""
+84
View File
@@ -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()
+83 -73
View File
@@ -1,99 +1,109 @@
import unittest import unittest
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory 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.modules.pruef_ergebnis import pruef_ergebnis
from sn_basis.functions.variable_wrapper import get_variable # type: ignore from sn_basis.functions.variable_wrapper import get_variable
from sn_basis.functions.sys_wrapper import file_exists # type: ignore from sn_plan41.ui.tab_a_logic import TabALogic
class _DummyPruefmanager:
def verarbeite(self, ergebnis):
return ergebnis
class TestTabALogic(unittest.TestCase): class TestTabALogic(unittest.TestCase):
# ----------------------------------------------------- def _make_logic(self) -> TabALogic:
# 1. Verfahrens-DB setzen und laden return TabALogic(
# ----------------------------------------------------- pruefmanager=cast(Any, _DummyPruefmanager()),
def test_verfahrens_db_set_and_load(self): link_pruefer=cast(Any, object()),
logic = TabALogic() stil_pruefer=cast(Any, object()),
)
with TemporaryDirectory() as tmp: def test_verfahrens_db_set_and_load_roundtrip(self):
db_path = Path(tmp) / "test.gpkg" logic = self._make_logic()
db_path.write_text("") 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): def test_verfahrens_db_clear(self):
logic = TabALogic() logic = self._make_logic()
logic.set_verfahrens_db(None) logic.set_verfahrens_db(None)
self.assertEqual(get_variable("verfahrens_db", scope="project"), "")
stored = get_variable("verfahrens_db", scope="project") def test_linkliste_set_and_load_existing_path(self):
self.assertEqual(stored, "") logic = self._make_logic()
# -----------------------------------------------------
# 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()
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
link_path = Path(tmp) / "links.xlsx" 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)) logic.set_linkliste(str(link_path))
self.assertEqual(logic.load_linkliste(), str(link_path))
stored = get_variable("linkliste", scope="project") def test_linkliste_load_returns_none_when_file_missing(self):
self.assertEqual(stored, str(link_path)) logic = self._make_logic()
logic.set_linkliste("C:/tmp/missing.xlsx")
self.assertIsNone(logic.load_linkliste())
loaded = logic.load_linkliste() @patch("sn_plan41.ui.tab_a_logic.get_plugin_root", return_value="C:/plugins")
self.assertEqual(loaded, str(link_path)) @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)
# ----------------------------------------------------- self.assertEqual(resolved, "C:/plugins/sn_plan41/assets/Linkliste.xlsx")
# 5. Linkliste löschen self.assertEqual(get_variable("linkliste", scope="project"), "C:/plugins/sn_plan41/assets/Linkliste.xlsx")
# -----------------------------------------------------
def test_linkliste_clear(self):
logic = TabALogic()
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") resolved = logic._resolve_linkliste("C:/tmp/missing.xlsx")
self.assertEqual(stored, "") self.assertIsNone(resolved)
# ----------------------------------------------------- def test_resolve_raumfilter_without_filter_returns_none(self):
# 6. Layer-ID speichern 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__": if __name__ == "__main__":
+94 -37
View File
@@ -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 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): class TestTabAUI(unittest.TestCase):
# ----------------------------------------------------- def _make_tab_without_qt(self):
# 1. UI kann erstellt werden tab = TabA.__new__(TabA)
# ----------------------------------------------------- tab.group_button = _DummyToggleButton()
def test_tab_a_ui_can_be_created(self): tab.group_content = _DummyWidget()
tab = TabA(parent=None,build_ui=False) 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) def test_tab_a_callbacks_can_be_called_without_crash(self):
self.assertEqual(tab.tab_title, "Daten") tab = self._make_tab_without_qt()
# -----------------------------------------------------
# 2. Toggle-Callbacks crashen nicht
# -----------------------------------------------------
def test_tab_a_toggle_callbacks_do_not_crash(self):
tab = TabA(parent=None,build_ui=False)
tab._toggle_group(True) tab._toggle_group(True)
tab._toggle_group(False) tab._toggle_group(False)
tab._toggle_optional(True) tab._toggle_optional(True)
tab._toggle_optional(False) 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_verfahrens_db_changed("")
tab._on_linkliste_changed("") tab._on_linkliste_changed("")
# ----------------------------------------------------- self.assertEqual(tab.group_content.visible, False)
# 4. Layer-Callback akzeptiert None self.assertEqual(tab.optional_content.visible, False)
# -----------------------------------------------------
def test_tab_a_layer_callback_accepts_none(self):
tab = TabA(parent=None,build_ui=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__": if __name__ == "__main__":
+20 -1
View File
@@ -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_basis.ui.tabs.settings_tab import SettingsTab
from sn_plan41.ui.tab_a_ui import TabA from sn_plan41.ui.tab_a_ui import TabA
from sn_basis.ui.tabs.print_tab import PrintTab from sn_basis.ui.tabs.print_tab import PrintTab
@@ -8,9 +11,25 @@ from sn_basis.functions.message_wrapper import error
class DockWidget(BaseDockWidget): 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] tabs = [TabA, PrintTab, SettingsTab]
def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None): 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) super().__init__(parent, subtitle)
# Services als Attribute speichern # Services als Attribute speichern
+15
View File
@@ -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 from qgis.PyQt.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit
class TabB(QWidget): class TabB(QWidget):
"""Beispiel-Tab für sn_plan41.
Attributes:
tab_title: Anzeigetitel des Tabs ("Tab B")
"""
tab_title = "Tab B" tab_title = "Tab B"
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialisiert den Tab mit Beispielinhalt.
Args:
parent: Übergeordnetes Widget
"""
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Plugin2 – Tab B")) layout.addWidget(QLabel("Plugin2 – Tab B"))