Compare commits

..
Author SHA1 Message Date
Daniel ab51838db5 Merge pull request 'test überarbeitet' (#24) from feature/Tests_erweitert into unstable
Release Plugin / release (push) Successful in 3s
Reviewed-on: #24
2026-04-22 09:22:49 +02:00
Daniel e3e858233d Merge branch 'unstable' of https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41 into feature/Tests_erweitert 2026-04-22 09:21:18 +02:00
release-bot cc2d5c6200 Release 26.4.2-unstable 2026-04-22 07:07:17 +00:00
Daniel b2ee167132 QgsmaxVersion eingefügt
Release Plugin / release (push) Successful in 4s
2026-04-22 09:03:54 +02:00
Daniel 600a3c7aff test überarbeitet 2026-04-20 15:36:29 +02:00
Daniel 8f9cf4c9fb pdoc-dockstrings ergänzt 2026-04-20 14:31:49 +02:00
release-bot 18573cb3be Release 26.4.2-testing 2026-04-10 09:32:49 +00:00
Daniel 6b0d94c54e Merge pull request 'Druckfunktion nach sn_basis verschben, Bugfixes' (#22) from unstable into testing
Release Plugin / release (push) Successful in 3s
Reviewed-on: #22
2026-04-10 11:30:03 +02:00
11 changed files with 327 additions and 116 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):
"""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)
+2
View File
@@ -1,4 +1,6 @@
---
Version 26.4.2-testing:
---
Version 26.04.1-unstable:
---
Version 26.3.3-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 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")
+2 -2
View File
@@ -1,12 +1,12 @@
[general]
version=26.04.1-unstable
version=26.4.2-unstable
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/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
+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 collections.abc import Mapping as _Mapping
# 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
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__":
+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
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__":
+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_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
+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
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"))