From e8fea163b570b0a1f11917e80de72557706c30a6 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 18 Dec 2025 22:00:31 +0100 Subject: [PATCH] =?UTF-8?q?Auf=20Wrapper=20umgestellt,=20Pr=C3=BCfarchitek?= =?UTF-8?q?tur=20QT6-kompatibel=20gemacht=20(Nicht=20lauff=C3=A4hig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 20 + .vscode/settings.json | 35 ++ __init__.py | 2 - functions/messages.py | 44 -- functions/qgisqt_wrapper.py | 880 ++++++++++++++++++++++++++++++++++++ functions/settings_logic.py | 72 +-- functions/styles.py | 28 -- functions/syswrapper.py | 185 ++++++++ functions/variable_utils.py | 35 -- main.py | 24 +- modules/Dateipruefer.py | 202 +++++---- modules/Pruefmanager.py | 168 +++++-- modules/layerpruefer.py | 170 +++++++ modules/linkpruefer.py | 178 +++++--- modules/pruef_ergebnis.py | 68 ++- modules/qt_compat.py | 111 ----- modules/stilpruefer.py | 87 ++-- test/run_tests.py | 107 +++-- test/test_bootstrap.py | 2 + test/test_dateipruefer.py | 151 ++++--- test/test_layerpruefer.py | 170 +++++++ test/test_linkpruefer.py | 144 +++--- test/test_pruefmanager.py | 179 +++++--- test/test_qt_compat.py | 100 ---- test/test_settings_logic.py | 60 +++ test/test_stilpruefer.py | 79 +++- test/test_wrapper.py | 164 +++++++ ui/base_dockwidget.py | 75 ++- ui/dockmanager.py | 50 +- ui/navigation.py | 1 + ui/tabs/settings_tab.py | 89 +++- 31 files changed, 2791 insertions(+), 889 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json delete mode 100644 functions/messages.py create mode 100644 functions/qgisqt_wrapper.py delete mode 100644 functions/styles.py create mode 100644 functions/syswrapper.py delete mode 100644 functions/variable_utils.py create mode 100644 modules/layerpruefer.py delete mode 100644 modules/qt_compat.py create mode 100644 test/test_bootstrap.py create mode 100644 test/test_layerpruefer.py delete mode 100644 test/test_qt_compat.py create mode 100644 test/test_settings_logic.py create mode 100644 test/test_wrapper.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0dfa213 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to QGIS (Port 5678)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis", + "remoteRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d8d3a84 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + // OSGeo Python als Interpreter (QGIS 4.0, Qt6) + "python.defaultInterpreterPath": "D:/OSGeo/apps/Python312/python.exe", + + // Pylance: zusätzliche Suchpfade + "python.analysis.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Autocomplete ebenfalls erweitern + "python.autoComplete.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Pylance-Modus + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticMode": "workspace", + + // Tests aktivieren + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./test", + "-p", + "*test*.py" + ] +} diff --git a/__init__.py b/__init__.py index f579213..854d63e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,3 @@ -from .functions.variable_utils import get_variable - def classFactory(iface): from .main import BasisPlugin return BasisPlugin(iface) diff --git a/functions/messages.py b/functions/messages.py deleted file mode 100644 index fadc330..0000000 --- a/functions/messages.py +++ /dev/null @@ -1,44 +0,0 @@ -# sn_basis/functions/messages.py - -from typing import Optional -from qgis.core import Qgis -from qgis.PyQt.QtWidgets import QWidget -from qgis.utils import iface - - -def push_message( - level: Qgis.MessageLevel, - title: str, - text: str, - duration: Optional[int] = 5, - parent: Optional[QWidget] = None, -): - """ - Zeigt eine Meldung in der QGIS-MessageBar. - - level: Qgis.Success | Qgis.Info | Qgis.Warning | Qgis.Critical - - title: Überschrift links (kurz halten) - - text: eigentliche Nachricht - - duration: Sekunden bis Auto-Ausblendung; None => bleibt sichtbar (mit Close-Button) - - parent: optionales Eltern-Widget (für Kontext), normalerweise nicht nötig - Rückgabe: MessageBarItem-Widget (kann später geschlossen/entfernt werden). - """ - bar = iface.messageBar() - # QGIS akzeptiert None als "sticky" Meldung - return bar.pushMessage(title, text, level=level, duration=duration) - - -def success(title: str, text: str, duration: int = 5): - return push_message(Qgis.Success, title, text, duration) - - -def info(title: str, text: str, duration: int = 5): - return push_message(Qgis.Info, title, text, duration) - - -def warning(title: str, text: str, duration: int = 5): - return push_message(Qgis.Warning, title, text, duration) - - -def error(title: str, text: str, duration: Optional[int] = 5): - # Fehler evtl. länger sichtbar lassen; setze duration=None falls gewünscht - return push_message(Qgis.Critical, title, text, duration) diff --git a/functions/qgisqt_wrapper.py b/functions/qgisqt_wrapper.py new file mode 100644 index 0000000..a8cd723 --- /dev/null +++ b/functions/qgisqt_wrapper.py @@ -0,0 +1,880 @@ +""" +sn_basis/functions/qgisqt_wrapper.py – zentrale QGIS/Qt-Abstraktion +""" + +from typing import Optional, Type, Any + + +# --------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------- + +def getattr_safe(obj: Any, name: str, default: Any = None) -> Any: + """ + Sichere getattr-Variante: + - fängt Exceptions beim Attributzugriff ab + - liefert default zurück, wenn Attribut fehlt oder fehlschlägt + """ + try: + return getattr(obj, name) + except Exception: + return default + + +# --------------------------------------------------------- +# Qt‑Symbole (werden später dynamisch importiert) +# --------------------------------------------------------- + +QMessageBox: Optional[Type[Any]] = None +QFileDialog: Optional[Type[Any]] = None +QEventLoop: Optional[Type[Any]] = None +QUrl: Optional[Type[Any]] = None +QNetworkRequest: Optional[Type[Any]] = None +QNetworkReply: Optional[Type[Any]] = None +QCoreApplication: Optional[Type[Any]] = None + +QWidget: Type[Any] +QGridLayout: Type[Any] +QLabel: Type[Any] +QLineEdit: Type[Any] +QGroupBox: Type[Any] +QVBoxLayout: Type[Any] +QPushButton: Type[Any] + +YES: Optional[Any] = None +NO: Optional[Any] = None +CANCEL: Optional[Any] = None +ICON_QUESTION: Optional[Any] = None + + +def exec_dialog(dialog: Any) -> Any: + raise NotImplementedError + + +# --------------------------------------------------------- +# QGIS‑Symbole (werden später dynamisch importiert) +# --------------------------------------------------------- + +QgsProject: Optional[Type[Any]] = None +QgsVectorLayer: Optional[Type[Any]] = None +QgsNetworkAccessManager: Optional[Type[Any]] = None +Qgis: Optional[Type[Any]] = None +iface: Optional[Any] = None + + +# --------------------------------------------------------- +# Qt‑Versionserkennung +# --------------------------------------------------------- + +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 + + +# --------------------------------------------------------- +# Versuch: PyQt6 importieren +# --------------------------------------------------------- + +try: + from PyQt6.QtWidgets import ( #type: ignore + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + ) + from PyQt6.QtCore import ( #type: ignore + Qt, + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + ) + from PyQt6.QtNetwork import ( #type: ignore + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + + if QMessageBox is not None: + YES = QMessageBox.StandardButton.Yes + NO = QMessageBox.StandardButton.No + CANCEL = QMessageBox.StandardButton.Cancel + ICON_QUESTION = QMessageBox.Icon.Question + + QT_VERSION = 6 + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec() + +# --------------------------------------------------------- +# Versuch: PyQt5 importieren +# --------------------------------------------------------- + +except Exception: + try: + from PyQt5.QtWidgets import ( + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + ) + from PyQt5.QtCore import ( + Qt, + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + ) + from PyQt5.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + + if QMessageBox is not None: + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + + QT_VERSION = 5 + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec_() + + # --------------------------------------------------------- + # Mock‑Modus (kein Qt verfügbar) + # --------------------------------------------------------- + + except Exception: + QT_VERSION = 0 + + class FakeEnum(int): + """OR‑fähiger Enum‑Ersatz für Mock‑Modus.""" + + def __new__(cls, value: int): + return int.__new__(cls, value) + + def __or__(self, other: "FakeEnum") -> "FakeEnum": + return FakeEnum(int(self) | int(other)) + + class _MockQMessageBox: + Yes = FakeEnum(1) + No = FakeEnum(2) + Cancel = FakeEnum(4) + Question = FakeEnum(8) + + QMessageBox = _MockQMessageBox + + class _MockQFileDialog: + @staticmethod + def getOpenFileName(*args, **kwargs): + return ("", "") + + @staticmethod + def getSaveFileName(*args, **kwargs): + return ("", "") + + QFileDialog = _MockQFileDialog + + class _MockQEventLoop: + def exec(self) -> int: + return 0 + + def quit(self) -> None: + pass + + QEventLoop = _MockQEventLoop + + class _MockQUrl(str): + def isValid(self) -> bool: + return True + + QUrl = _MockQUrl + + class _MockQNetworkRequest: + def __init__(self, url: Any): + self.url = url + + QNetworkRequest = _MockQNetworkRequest + + class _MockQNetworkReply: + class NetworkError: + NoError = 0 + + def __init__(self): + self._data = b"" + + def error(self) -> int: + return 0 + + def errorString(self) -> str: + return "" + + def attribute(self, *args, **kwargs) -> Any: + return 200 + + def readAll(self) -> bytes: + return self._data + + def deleteLater(self) -> None: + pass + + QNetworkReply = _MockQNetworkReply + + YES = FakeEnum(1) + NO = FakeEnum(2) + CANCEL = FakeEnum(4) + ICON_QUESTION = FakeEnum(8) + + def exec_dialog(dialog: Any) -> Any: + return YES + + class _MockWidget: + def __init__(self, *args, **kwargs): + pass + + class _MockLayout: + def __init__(self, *args, **kwargs): + pass + + def addWidget(self, *args, **kwargs): + pass + + def addLayout(self, *args, **kwargs): + pass + + def addStretch(self, *args, **kwargs): + pass + + def setLayout(self, *args, **kwargs): + pass + + class _MockLabel: + def __init__(self, text: str = ""): + self._text = text + + class _MockLineEdit: + def __init__(self, *args, **kwargs): + self._text = "" + + def text(self) -> str: + return self._text + + def setText(self, value: str) -> None: + self._text = value + + class _MockButton: + def __init__(self, *args, **kwargs): + # einfache Attr für Kompatibilität mit Qt-Signal-Syntax + self.clicked = lambda *a, **k: None + + def connect(self, *args, **kwargs): + pass + + QWidget = _MockWidget + QGridLayout = _MockLayout + QLabel = _MockLabel + QLineEdit = _MockLineEdit + QGroupBox = _MockWidget + QVBoxLayout = _MockLayout + QPushButton = _MockButton + + # Kein echtes QCoreApplication im Mock + QCoreApplication = None + + +# --------------------------------------------------------- +# QGIS‑Imports +# --------------------------------------------------------- + +try: + from qgis.core import ( + QgsProject as _QgsProject, + QgsVectorLayer as _QgsVectorLayer, + QgsNetworkAccessManager as _QgsNetworkAccessManager, + Qgis as _Qgis, + ) + from qgis.utils import iface as _iface + + QgsProject = _QgsProject + QgsVectorLayer = _QgsVectorLayer + QgsNetworkAccessManager = _QgsNetworkAccessManager + Qgis = _Qgis + iface = _iface + + QGIS_AVAILABLE = True + +except Exception: + QGIS_AVAILABLE = False + + class _MockQgsProject: + @staticmethod + def instance() -> "_MockQgsProject": + return _MockQgsProject() + + def __init__(self): + self._variables = {} + + def read(self) -> bool: + return True + + QgsProject = _MockQgsProject + + class _MockQgsVectorLayer: + def __init__(self, *args, **kwargs): + self._valid = True + + def isValid(self) -> bool: + return self._valid + + def loadNamedStyle(self, path: str): + return True, "" + + def triggerRepaint(self) -> None: + pass + + QgsVectorLayer = _MockQgsVectorLayer + + class _MockQgsNetworkAccessManager: + def head(self, request: Any) -> _MockQNetworkReply: + return _MockQNetworkReply() + + QgsNetworkAccessManager = _MockQgsNetworkAccessManager + + class _MockQgis: + class MessageLevel: + Success = 0 + Info = 1 + Warning = 2 + Critical = 3 + + Qgis = _MockQgis + + class FakeIface: + class FakeMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return {"title": title, "text": text, "level": level, "duration": duration} + + def messageBar(self): + return self.FakeMessageBar() + + def mainWindow(self): + return None + + iface = FakeIface() + + +# --------------------------------------------------------- +# Message‑Funktionen +# --------------------------------------------------------- + +def _get_message_bar(): + if iface is not None: + bar_attr = getattr_safe(iface, "messageBar") + if callable(bar_attr): + try: + return bar_attr() + except Exception: + pass + + class _MockMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return { + "title": title, + "text": text, + "level": level, + "duration": duration, + } + + return _MockMessageBar() + + +def push_message(level, title, text, duration=5, parent=None): + bar = _get_message_bar() + push = getattr_safe(bar, "pushMessage") + if callable(push): + return push(title, text, level=level, duration=duration) + return None + + +def info(title, text, duration=5): + level = Qgis.MessageLevel.Info if Qgis is not None else 1 + return push_message(level, title, text, duration) + + +def warning(title, text, duration=5): + level = Qgis.MessageLevel.Warning if Qgis is not None else 2 + return push_message(level, title, text, duration) + + +def error(title, text, duration=5): + level = Qgis.MessageLevel.Critical if Qgis is not None else 3 + return push_message(level, title, text, duration) + + +def success(title, text, duration=5): + level = Qgis.MessageLevel.Success if Qgis is not None else 0 + return push_message(level, title, text, duration) + +# --------------------------------------------------------- +# Dialog‑Interaktionen +# --------------------------------------------------------- + +def ask_yes_no( + title: str, + message: str, + default: bool = False, + parent: Any = None, +) -> bool: + """ + Fragt den Benutzer eine Ja/Nein‑Frage. + + - In QGIS/Qt: zeigt einen QMessageBox‑Dialog + - Im Mock/Test‑Modus: gibt default zurück + """ + if QMessageBox is None: + return default + + try: + buttons = YES | NO + result = QMessageBox.question( + parent, + title, + message, + buttons, + YES if default else NO, + ) + return result == YES + except Exception: + return default + + +# --------------------------------------------------------- +# Variablen‑Wrapper +# --------------------------------------------------------- + +try: + from qgis.core import QgsExpressionContextUtils + + _HAS_QGIS_VARIABLES = True +except Exception: + _HAS_QGIS_VARIABLES = False + + class _MockVariableStore: + global_vars: dict[str, str] = {} + project_vars: dict[str, str] = {} + + class QgsExpressionContextUtils: + @staticmethod + def setGlobalVariable(name: str, value: str) -> None: + _MockVariableStore.global_vars[name] = value + + @staticmethod + def globalScope(): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.global_vars.get(name, "") + + return _Scope() + + @staticmethod + def setProjectVariable(project: Any, name: str, value: str) -> None: + _MockVariableStore.project_vars[name] = value + + @staticmethod + def projectScope(project: Any): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.project_vars.get(name, "") + + return _Scope() + + +def get_variable(key: str, scope: str = "project") -> str: + var_name = f"sn_{key}" + + if scope == "project": + if QgsProject is not None: + projekt = QgsProject.instance() + else: + projekt = None # type: ignore[assignment] + return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or "" + + if scope == "global": + return QgsExpressionContextUtils.globalScope().variable(var_name) or "" + + raise ValueError("Scope muss 'project' oder 'global' sein.") + + +def set_variable(key: str, value: str, scope: str = "project") -> None: + var_name = f"sn_{key}" + + if scope == "project": + if QgsProject is not None: + projekt = QgsProject.instance() + else: + projekt = None # type: ignore[assignment] + QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value) + return + + if scope == "global": + QgsExpressionContextUtils.setGlobalVariable(var_name, value) + return + + raise ValueError("Scope muss 'project' oder 'global' sein.") + + +# --------------------------------------------------------- +# syswrapper Lazy‑Import +# --------------------------------------------------------- + +def _sys(): + from sn_basis.functions import syswrapper + return syswrapper + + +# --------------------------------------------------------- +# Style‑Funktion +# --------------------------------------------------------- + +def apply_style(layer, style_name: str) -> bool: + if layer is None: + return False + + is_valid_attr = getattr_safe(layer, "isValid") + if not callable(is_valid_attr) or not is_valid_attr(): + return False + + sys = _sys() + base_dir = sys.get_plugin_root() + style_path = sys.join_path(base_dir, "styles", style_name) + + if not sys.file_exists(style_path): + return False + + try: + ok, error_msg = layer.loadNamedStyle(style_path) + except Exception: + return False + + if not ok: + return False + + try: + trigger = getattr_safe(layer, "triggerRepaint") + if callable(trigger): + trigger() + except Exception: + pass + + return True + + +# --------------------------------------------------------- +# Layer‑Wrapper +# --------------------------------------------------------- + +def layer_exists(layer) -> bool: + if layer is None: + return False + + # Mock/Wrapper-Attribut + is_valid_flag = getattr_safe(layer, "is_valid") + if is_valid_flag is not None: + try: + return bool(is_valid_flag) + except Exception: + return False + + try: + is_valid_attr = getattr_safe(layer, "isValid") + if callable(is_valid_attr): + return bool(is_valid_attr()) + return True + except Exception: + return False + + +def get_layer_geometry_type(layer) -> str: + if layer is None: + return "None" + + geometry_type_attr = getattr_safe(layer, "geometry_type") + if geometry_type_attr is not None: + return str(geometry_type_attr) + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr) and not is_spatial_attr(): + return "None" + + geometry_type_qgis = getattr_safe(layer, "geometryType") + if callable(geometry_type_qgis): + gtype = geometry_type_qgis() + if gtype == 0: + return "Point" + if gtype == 1: + return "LineString" + if gtype == 2: + return "Polygon" + return "None" + + return "None" + except Exception: + return "None" + + +def get_layer_feature_count(layer) -> int: + if layer is None: + return 0 + + feature_count_attr = getattr_safe(layer, "feature_count") + if feature_count_attr is not None: + try: + return int(feature_count_attr) + except Exception: + return 0 + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr) and not is_spatial_attr(): + return 0 + + feature_count_qgis = getattr_safe(layer, "featureCount") + if callable(feature_count_qgis): + return int(feature_count_qgis()) + + return 0 + except Exception: + return 0 + + +def is_layer_visible(layer) -> bool: + if layer is None: + return False + + visible_attr = getattr_safe(layer, "visible") + if visible_attr is not None: + try: + return bool(visible_attr) + except Exception: + return False + + try: + is_visible_attr = getattr_safe(layer, "isVisible") + if callable(is_visible_attr): + return bool(is_visible_attr()) + + tree_layer_attr = getattr_safe(layer, "treeLayer") + if callable(tree_layer_attr): + node = tree_layer_attr() + else: + node = tree_layer_attr + + if node is not None: + node_visible_attr = getattr_safe(node, "isVisible") + if callable(node_visible_attr): + return bool(node_visible_attr()) + + return False + except Exception: + return False + +def set_layer_visible(layer, visible: bool) -> bool: + """ + Setzt die Sichtbarkeit eines Layers. + + Unterstützt: + - Mock-/Wrapper-Attribute (layer.visible) + - QGIS-LayerTreeNode (treeLayer().setItemVisibilityChecked) + - Fallbacks ohne Exception-Wurf + + Gibt True zurück, wenn die Sichtbarkeit gesetzt werden konnte. + """ + if layer is None: + return False + + # 1️⃣ Mock / Wrapper-Attribut + try: + if hasattr(layer, "visible"): + layer.visible = bool(visible) + return True + except Exception: + pass + + # 2️⃣ QGIS: LayerTreeNode + try: + tree_layer_attr = getattr_safe(layer, "treeLayer") + node = tree_layer_attr() if callable(tree_layer_attr) else tree_layer_attr + + if node is not None: + set_visible = getattr_safe(node, "setItemVisibilityChecked") + if callable(set_visible): + set_visible(bool(visible)) + return True + except Exception: + pass + + # 3️⃣ QGIS-Fallback: setVisible (selten, aber vorhanden) + try: + set_visible_attr = getattr_safe(layer, "setVisible") + if callable(set_visible_attr): + set_visible_attr(bool(visible)) + return True + except Exception: + pass + + return False + + +def get_layer_type(layer) -> str: + if layer is None: + return "unknown" + + layer_type_attr = getattr_safe(layer, "layer_type") + if layer_type_attr is not None: + return str(layer_type_attr) + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr): + return "vector" if is_spatial_attr() else "table" + + data_provider_attr = getattr_safe(layer, "dataProvider") + raster_type_attr = getattr_safe(layer, "rasterType") + if data_provider_attr is not None and raster_type_attr is not None: + return "raster" + + return "unknown" + except Exception: + return "unknown" + + +def get_layer_crs(layer) -> str: + if layer is None: + return "None" + + crs_attr_direct = getattr_safe(layer, "crs") + if crs_attr_direct is not None and not callable(crs_attr_direct): + # direkter Attributzugriff (z. B. im Mock) + return str(crs_attr_direct) + + try: + crs_callable = getattr_safe(layer, "crs") + if callable(crs_callable): + crs = crs_callable() + authid_attr = getattr_safe(crs, "authid") + if callable(authid_attr): + return authid_attr() or "None" + return "None" + except Exception: + return "None" + + +def get_layer_fields(layer) -> list[str]: + if layer is None: + return [] + + # direkter Attributzugriff (Mock / Wrapper) + fields_attr_direct = getattr_safe(layer, "fields") + if fields_attr_direct is not None and not callable(fields_attr_direct): + try: + # direkter Iterable oder Mapping von Namen + if hasattr(fields_attr_direct, "__iter__") and not isinstance( + fields_attr_direct, (str, bytes) + ): + return list(fields_attr_direct) + except Exception: + return [] + + try: + fields_callable = getattr_safe(layer, "fields") + if callable(fields_callable): + fields = fields_callable() + + # QGIS: QgsFields.names() + names_attr = getattr_safe(fields, "names") + if callable(names_attr): + return list(names_attr()) + + # Fallback: iterierbar? + if hasattr(fields, "__iter__") and not isinstance(fields, (str, bytes)): + return list(fields) + + return [] + except Exception: + return [] + + +def get_layer_source(layer) -> str: + if layer is None: + return "None" + + source_attr_direct = getattr_safe(layer, "source") + if source_attr_direct is not None and not callable(source_attr_direct): + return str(source_attr_direct) + + try: + source_callable = getattr_safe(layer, "source") + if callable(source_callable): + return source_callable() or "None" + return "None" + except Exception: + return "None" + + +def is_layer_editable(layer) -> bool: + if layer is None: + return False + + editable_attr = getattr_safe(layer, "editable") + if editable_attr is not None: + try: + return bool(editable_attr) + except Exception: + return False + + try: + editable_callable = getattr_safe(layer, "isEditable") + if callable(editable_callable): + return bool(editable_callable()) + return False + except Exception: + return False diff --git a/functions/settings_logic.py b/functions/settings_logic.py index 64b209a..77d049c 100644 --- a/functions/settings_logic.py +++ b/functions/settings_logic.py @@ -1,37 +1,47 @@ -from qgis.core import QgsProject, QgsExpressionContextUtils +""" +sn_basis/funktions/settings_logic.py – Logik zum Lesen und Schreiben der Plugin-Einstellungen +über den zentralen qgisqt_wrapper. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + get_variable, + set_variable, +) + class SettingsLogic: - def __init__(self): - self.project = QgsProject.instance() + """ + Verwaltet das Laden und Speichern der Plugin-Einstellungen. + Alle Variablen werden als sn_* Projektvariablen gespeichert. + """ - # Definition der Variablen-Namen - self.global_vars = ["amt", "behoerde", "landkreis_user", "sachgebiet"] - self.project_vars = ["bezeichnung", "verfahrensnummer", "gemeinden", "landkreise_proj"] - - def save(self, fields: dict): - """Speichert Felder als globale und projektbezogene Ausdrucksvariablen.""" - - # Globale Variablen - for key in self.global_vars: - QgsExpressionContextUtils.setGlobalVariable(f"sn_{key}", fields.get(key, "")) - - # Projektvariablen - for key in self.project_vars: - QgsExpressionContextUtils.setProjectVariable(self.project, f"sn_{key}", fields.get(key, "")) - - print("✅ Ausdrucksvariablen gespeichert.") + # Alle Variablen, die gespeichert werden sollen + VARIABLEN = [ + "amt", + "behoerde", + "landkreis_user", + "sachgebiet", + "bezeichnung", + "verfahrensnummer", + "gemeinden", + "landkreise_proj", + ] def load(self) -> dict: - """Lädt Werte ausschließlich aus Ausdrucksvariablen (global + projektbezogen).""" + """ + Lädt alle Variablen aus dem Projekt. + Rückgabe: dict mit allen Werten (leere Strings, wenn nicht gesetzt). + """ + daten = {} + for key in self.VARIABLEN: + daten[key] = get_variable(key, scope="project") + return daten - data = {} - - # Globale Variablen - for key in self.global_vars: - data[key] = QgsExpressionContextUtils.globalScope().variable(f"sn_{key}") or "" - - # Projektvariablen - for key in self.project_vars: - data[key] = QgsExpressionContextUtils.projectScope(self.project).variable(f"sn_{key}") or "" - - return data + def save(self, daten: dict): + """ + Speichert alle übergebenen Variablen im Projekt. + daten: dict mit key → value + """ + for key, value in daten.items(): + if key in self.VARIABLEN: + set_variable(key, value, scope="project") diff --git a/functions/styles.py b/functions/styles.py deleted file mode 100644 index 0723717..0000000 --- a/functions/styles.py +++ /dev/null @@ -1,28 +0,0 @@ -# sn_basis/functions/styles.py -import os -from qgis.core import QgsVectorLayer - -def apply_style(layer: QgsVectorLayer, style_name: str) -> bool: - """ - Lädt einen QML-Style aus dem styles-Ordner des Plugins und wendet ihn auf den Layer an. - style_name: Dateiname ohne Pfad, z.B. 'verfahrensgebiet.qml' - Rückgabe: True bei Erfolg, False sonst - """ - if not layer or not layer.isValid(): - return False - - # Basis-Pfad: sn_basis/styles - base_dir = os.path.dirname(os.path.dirname(__file__)) # geht von functions/ eins hoch - style_path = os.path.join(base_dir, "styles", style_name) - - if not os.path.exists(style_path): - print(f"Style-Datei nicht gefunden: {style_path}") - return False - - ok, error_msg = layer.loadNamedStyle(style_path) - if not ok: - print(f"Style konnte nicht geladen werden: {error_msg}") - return False - - layer.triggerRepaint() - return True diff --git a/functions/syswrapper.py b/functions/syswrapper.py new file mode 100644 index 0000000..2ab5a6b --- /dev/null +++ b/functions/syswrapper.py @@ -0,0 +1,185 @@ +""" +snbasis/functions/syswrapper.py – zentrale OS-/Dateisystem-Abstraktion +Robust, testfreundlich, mock-fähig. +""" + +import os +import tempfile +import pathlib +import sys + + +# --------------------------------------------------------- +# Dateisystem‑Funktionen +# --------------------------------------------------------- + +def file_exists(path: str) -> bool: + """Prüft, ob eine Datei existiert.""" + try: + return os.path.exists(path) + except Exception: + return False + + +def is_file(path: str) -> bool: + """Prüft, ob ein Pfad eine Datei ist.""" + try: + return os.path.isfile(path) + except Exception: + return False + + +def is_dir(path: str) -> bool: + """Prüft, ob ein Pfad ein Verzeichnis ist.""" + try: + return os.path.isdir(path) + except Exception: + return False + + +def join_path(*parts) -> str: + """Verbindet Pfadbestandteile OS‑unabhängig.""" + try: + return os.path.join(*parts) + except Exception: + # Fallback: naive Verkettung + return "/".join(str(p) for p in parts) + + +# --------------------------------------------------------- +# Pfad‑ und Systemfunktionen +# --------------------------------------------------------- + +def get_temp_dir() -> str: + """Gibt das temporäre Verzeichnis zurück.""" + try: + return tempfile.gettempdir() + except Exception: + return "/tmp" + + +def get_plugin_root() -> str: + """ + Ermittelt den Plugin‑Root‑Pfad. + Annahme: syswrapper liegt in sn_basis/funktions/ + → also zwei Ebenen hoch. + """ + try: + here = pathlib.Path(__file__).resolve() + return str(here.parent.parent) + except Exception: + # Fallback: aktuelles Arbeitsverzeichnis + return os.getcwd() + + +# --------------------------------------------------------- +# Datei‑I/O (optional, aber nützlich) +# --------------------------------------------------------- + +def read_file(path: str, mode="r"): + """Liest eine Datei ein. Gibt None zurück, wenn Fehler auftreten.""" + try: + with open(path, mode) as f: + return f.read() + except Exception: + return None + + +def write_file(path: str, data, mode="w"): + """Schreibt Daten in eine Datei. Gibt True/False zurück.""" + try: + with open(path, mode) as f: + f.write(data) + return True + except Exception: + return False + + +# --------------------------------------------------------- +# Mock‑Modus (optional erweiterbar) +# --------------------------------------------------------- + +class FakeFileSystem: + """ + Minimaler Mock‑Dateisystem‑Ersatz. + Wird nicht automatisch aktiviert, aber kann in Tests gepatcht werden. + """ + files = {} + + @classmethod + def add_file(cls, path, content=""): + cls.files[path] = content + + @classmethod + def exists(cls, path): + return path in cls.files + + @classmethod + def read(cls, path): + return cls.files.get(path, None) + +# --------------------------------------------------------- +# Betriebssystem‑Erkennung +# --------------------------------------------------------- + +import platform + +def get_os() -> str: + """ + Gibt das Betriebssystem zurück: + - 'windows' + - 'linux' + - 'mac' + """ + system = platform.system().lower() + + if "windows" in system: + return "windows" + if "darwin" in system: + return "mac" + if "linux" in system: + return "linux" + + return "unknown" + + +def is_windows() -> bool: + return get_os() == "windows" + + +def is_linux() -> bool: + return get_os() == "linux" + + +def is_mac() -> bool: + return get_os() == "mac" + + +# --------------------------------------------------------- +# Pfad‑Normalisierung +# --------------------------------------------------------- + +def normalize_path(path: str) -> str: + """ + Normalisiert Pfade OS‑unabhängig: + - ersetzt Backslashes durch Slashes + - entfernt doppelte Slashes + - löst relative Pfade auf + """ + try: + p = pathlib.Path(path).resolve() + return str(p) + except Exception: + # Fallback: einfache Normalisierung + return path.replace("\\", "/").replace("//", "/") + +def add_to_sys_path(path: str) -> None: + """ + Fügt einen Pfad sicher zum Python-Importpfad hinzu. + """ + try: + if path not in sys.path: + sys.path.insert(0, path) + except Exception: + pass + diff --git a/functions/variable_utils.py b/functions/variable_utils.py deleted file mode 100644 index 6ac4af9..0000000 --- a/functions/variable_utils.py +++ /dev/null @@ -1,35 +0,0 @@ -from qgis.core import QgsProject, QgsExpressionContextUtils - -def get_variable(key: str, scope: str = "project") -> str: - """ - Liefert den Wert einer sn_* Variable zurück. - key: Name ohne Präfix, z.B. "verfahrensnummer" - scope: 'project' oder 'global' - """ - projekt = QgsProject.instance() - var_name = f"sn_{key}" - - if scope == "project": - return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or "" - elif scope == "global": - return QgsExpressionContextUtils.globalScope().variable(var_name) or "" - else: - raise ValueError("Scope muss 'project' oder 'global' sein.") - - -def set_variable(key: str, value: str, scope: str = "project"): - """ - Schreibt den Wert einer sn_* Variable. - key: Name ohne Präfix, z.B. "verfahrensnummer" - value: Wert, der gespeichert werden soll - scope: 'project' oder 'global' - """ - projekt = QgsProject.instance() - var_name = f"sn_{key}" - - if scope == "project": - QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value) - elif scope == "global": - QgsExpressionContextUtils.setGlobalVariable(var_name, value) - else: - raise ValueError("Scope muss 'project' oder 'global' sein.") diff --git a/main.py b/main.py index 8fc2b7f..d71c114 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,24 @@ -from qgis.PyQt.QtCore import QCoreApplication +# sn_basis/main.py + +from sn_basis.functions.qgisqt_wrapper import QCoreApplication, getattr_safe from qgis.utils import plugins from sn_basis.ui.navigation import Navigation + class BasisPlugin: def __init__(self, iface): self.iface = iface self.ui = None - QCoreApplication.instance().aboutToQuit.connect(self.unload) + + # QCoreApplication kann im Mock-Modus None sein + if QCoreApplication is not None: + app = getattr_safe(QCoreApplication, "instance") + if callable(app): + instance = app() + about_to_quit = getattr_safe(instance, "aboutToQuit") + connect = getattr_safe(about_to_quit, "connect") + if callable(connect): + connect(self.unload) def initGui(self): # Basis-Navigation neu aufbauen @@ -16,11 +28,15 @@ class BasisPlugin: for name, plugin in plugins.items(): if name.startswith("sn_") and name != "sn_basis": try: - plugin.initGui() + init_gui = getattr_safe(plugin, "initGui") + if callable(init_gui): + init_gui() except Exception as e: print(f"Fehler beim Neuinitialisieren von {name}: {e}") def unload(self): if self.ui: - self.ui.remove_all() + remove_all = getattr_safe(self.ui, "remove_all") + if callable(remove_all): + remove_all() self.ui = None diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 8e29eb1..a2fd17e 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,100 +1,126 @@ -#Modul zur Prüfung und zum Exception Handling für Dateieingaben -#Dateipruefer.py +""" +sn_basis/modulesdateipruefer.py – Prüfung von Dateieingaben für das Plugin. +Verwendet syswrapper und gibt pruef_ergebnis an den Pruefmanager zurück. +""" -import os -from enum import Enum, auto +from sn_basis.functions.syswrapper import ( + file_exists, + is_file, + join_path, +) + +from sn_basis.modules.Pruefmanager import pruef_ergebnis -# ------------------------------- -# ENUMS -# ------------------------------- -class LeererPfadModus(Enum):#legt die modi fest, die für Dateipfade möglich sind - VERBOTEN = auto() #ein leeres Eingabefeld stellt einen Fehler dar - NUTZE_STANDARD = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: nutze Standard oder brich ab - TEMPORAER_ERLAUBT = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: arbeite temporär oder brich ab. - - -class DateiEntscheidung(Enum):#legt die Modi fest, wie mit bestehenden Dateien umgegangen werden soll (hat das das QGSFile-Objekt schon selbst?) - ERSETZEN = auto()#Ergebnis der Nutzerentscheidung: bestehende Datei ersetzen - ANHAENGEN = auto()#Ergebnis der Nutzerentscheidung: an bestehende Datei anhängen - ABBRECHEN = auto()#bricht den Vorgang ab. (muss das eine definierte Option sein? oder geht das auch mit einem normalen Abbruch-Button) - - -# ------------------------------- -# RÜCKGABEOBJEKT -# ------------------------------- -#Das Dateiprüfergebnis wird an den Prüfmanager übergeben. Alle GUI-Abfragen werden im Prüfmanager behandelt. -class DateipruefErgebnis: - #Definition der Parameter und Festlegung auf den Parametertyp,bzw den Standardwert - def __init__(self, erfolgreich: bool, pfad: str = None, temporär: bool = False, - entscheidung: DateiEntscheidung = None, fehler: list = None): - self.erfolgreich = erfolgreich - self.pfad = pfad - self.temporär = temporär - self.entscheidung = entscheidung - self.fehler = fehler or [] - - def __repr__(self): - return (f"DateipruefErgebnis(erfolgreich={self.erfolgreich}, " - f"pfad={repr(self.pfad)}, temporär={self.temporär}, " - f"entscheidung={repr(self.entscheidung)}, fehler={repr(self.fehler)})") - -# ------------------------------- -# DATEIPRÜFER -# ------------------------------- class Dateipruefer: - def pruefe(self, pfad: str, - leer_modus: LeererPfadModus, - standardname: str = None, - plugin_pfad: str = None, - vorhandene_datei_entscheidung: DateiEntscheidung = None) -> DateipruefErgebnis: #Rückgabetypannotation; "Die Funktion "pruefe" gibt ein Objekt vom Typ "DateipruefErgebnis" zurück + """ + Prüft Dateieingaben und liefert ein pruef_ergebnis zurück. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + """ - # 1. Prüfe, ob das Eingabefeld leer ist - if not pfad or pfad.strip() == "":#wenn der angegebene Pfad leer oder ungültig ist: - if leer_modus == LeererPfadModus.VERBOTEN: #wenn der Modus "verboten" vorgegeben ist, gib zurück, dass der Test fehlgeschlagen ist - return DateipruefErgebnis( - erfolgreich=False, - fehler=["Kein Pfad angegeben."] - ) - elif leer_modus == LeererPfadModus.NUTZE_STANDARD:#wenn der Modus "Nutze_Standard" vorgegeben ist... - if not plugin_pfad or not standardname:#wenn kein gültiger Pluginpfad angegeben ist oder die Standarddatei fehlt... - return DateipruefErgebnis( - erfolgreich=False, - fehler=["Standardpfad oder -name fehlen."]#..gib zurück, dass der Test fehlgeschlagen ist - ) - pfad = os.path.join(plugin_pfad, standardname)#...wenn es Standarddatei und Pluginpfad gibt...setze sie zum Pfad zusammen... - elif leer_modus == LeererPfadModus.TEMPORAER_ERLAUBT:#wenn der Modus "temporär" vorgegeben ist,... - return DateipruefErgebnis(#...gib zurück, dass das Prüfergebnis erfolgreich ist (Entscheidung, ob temporör gearbeitet werden soll oder nicht, kommt woanders) - erfolgreich=True, - pfad=None - ) + def __init__( + self, + pfad: str, + basis_pfad: str = "", + leereingabe_erlaubt: bool = False, + standarddatei: str | None = None, + temporaer_erlaubt: bool = False, + ): + self.pfad = pfad + self.basis_pfad = basis_pfad + self.leereingabe_erlaubt = leereingabe_erlaubt + self.standarddatei = standarddatei + self.temporaer_erlaubt = temporaer_erlaubt - # 2. Existiert die Datei bereits? - if os.path.exists(pfad):#wenn die Datei vorhanden ist... - if not vorhandene_datei_entscheidung:#aber noch keine Entscheidung getroffen ist... - return DateipruefErgebnis( - erfolgreich=True,#ist die Prüfung erfolgreich, aber es muss noch eine Entscheidung verlangt werden - pfad=pfad, - entscheidung=None, - fehler=["Datei existiert bereits – Entscheidung ausstehend."] - ) - if vorhandene_datei_entscheidung == DateiEntscheidung.ABBRECHEN: - return DateipruefErgebnis(#...der Nutzer aber abgebrochen hat... - erfolgreich=False,#ist die Prüfung fehlgeschlagen ISSUE: ergibt das Sinn? - pfad=pfad, - fehler=["Benutzer hat abgebrochen."] - ) + # --------------------------------------------------------- + # Hilfsfunktion + # --------------------------------------------------------- - return DateipruefErgebnis( - erfolgreich=True, + def _pfad(self, relativer_pfad: str) -> str: + """Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis.""" + return join_path(self.basis_pfad, relativer_pfad) + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + """ + Prüft eine Dateieingabe und liefert ein pruef_ergebnis zurück. + Der Pruefmanager entscheidet später, wie der Nutzer gefragt wird. + """ + + # ----------------------------------------------------- + # 1. Fall: Eingabe ist leer + # ----------------------------------------------------- + if not self.pfad: + return self._handle_leere_eingabe() + + # ----------------------------------------------------- + # 2. Fall: Eingabe ist nicht leer → Datei prüfen + # ----------------------------------------------------- + pfad = self._pfad(self.pfad) + + if not file_exists(pfad) or not is_file(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Die Datei '{self.pfad}' wurde nicht gefunden.", + aktion="datei_nicht_gefunden", pfad=pfad, - entscheidung=vorhandene_datei_entscheidung ) - # 3. Pfad gültig und Datei nicht vorhanden - #wenn alle Varianten NICHT zutreffen, weil ein gültiger Pfad eingegeben wurde und die Datei noch nicht vorhanden ist: - return DateipruefErgebnis( - erfolgreich=True, - pfad=pfad + # ----------------------------------------------------- + # 3. Datei existiert → Erfolg + # ----------------------------------------------------- + return pruef_ergebnis( + ok=True, + meldung="Datei gefunden.", + aktion="ok", + pfad=pfad, + ) + + # --------------------------------------------------------- + # Behandlung leerer Eingaben + # --------------------------------------------------------- + + def _handle_leere_eingabe(self) -> pruef_ergebnis: + """ + Liefert ein pruef_ergebnis für den Fall, dass das Dateifeld leer ist. + Der Pruefmanager fragt später den Nutzer. + """ + + # 1. Leereingabe erlaubt → Nutzer fragen, ob das beabsichtigt war + if self.leereingabe_erlaubt: + return pruef_ergebnis( + ok=False, + meldung="Das Dateifeld ist leer. Soll ohne Datei fortgefahren werden?", + aktion="leereingabe_erlaubt", + pfad=None, + ) + + # 2. Standarddatei verfügbar → Nutzer fragen, ob sie verwendet werden soll + if self.standarddatei: + return pruef_ergebnis( + ok=False, + meldung=f"Es wurde keine Datei angegeben. Soll die Standarddatei '{self.standarddatei}' verwendet werden?", + aktion="standarddatei_vorschlagen", + pfad=self._pfad(self.standarddatei), + ) + + # 3. Temporäre Datei erlaubt → Nutzer fragen, ob temporär gearbeitet werden soll + if self.temporaer_erlaubt: + return pruef_ergebnis( + ok=False, + meldung="Es wurde keine Datei angegeben. Soll eine temporäre Datei erzeugt werden?", + aktion="temporaer_erlaubt", + pfad=None, + ) + + # 4. Leereingabe nicht erlaubt → Fehler + return pruef_ergebnis( + ok=False, + meldung="Es wurde keine Datei angegeben.", + aktion="leereingabe_nicht_erlaubt", + pfad=None, ) diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index a766aa3..5eea743 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,52 +1,138 @@ -#Pruefmanager.py -from modules.qt_compat import QMessageBox, QFileDialog, YES, NO, CANCEL, ICON_QUESTION, exec_dialog -from modules.Dateipruefer import DateiEntscheidung +""" +sn_basis/modules/pruefmanager.py – zentrale Verarbeitung von pruef_ergebnis-Objekten. +Steuert die Nutzerinteraktion über qgisqt_wrapper. +""" -class PruefManager: +from sn_basis.functions.qgisqt_wrapper import ( + ask_yes_no, + info, + warning, + error, + set_layer_visible, # optional, falls implementiert +) - def __init__(self, iface=None, plugin_pfad=None): - self.iface = iface - self.plugin_pfad = plugin_pfad +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis - def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: - msg = QMessageBox() - msg.setIcon(ICON_QUESTION) - msg.setWindowTitle("Datei existiert") - msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") - msg.setStandardButtons(YES | NO | CANCEL) - msg.setDefaultButton(YES) +class Pruefmanager: + """ + Verarbeitet pruef_ergebnis-Objekte und steuert die Nutzerinteraktion. + """ - msg.button(YES).setText("Ersetzen") - msg.button(NO).setText("Anhängen") - msg.button(CANCEL).setText("Abbrechen") + def __init__(self, ui_modus: str = "qgis"): + self.ui_modus = ui_modus - result = exec_dialog(msg) + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- - if result == YES: - return DateiEntscheidung.ERSETZEN - elif result == NO: - return DateiEntscheidung.ANHAENGEN - else: - return DateiEntscheidung.ABBRECHEN + def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """ + Verarbeitet ein pruef_ergebnis und führt ggf. Nutzerinteraktion durch. + Rückgabe: neues oder unverändertes pruef_ergebnis. + """ - def frage_temporär_verwenden(self) -> bool: - msg = QMessageBox() - msg.setIcon(ICON_QUESTION) - msg.setWindowTitle("Temporäre Layer") - msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") + if ergebnis.ok: + return ergebnis - msg.setStandardButtons(YES | NO) - msg.setDefaultButton(YES) + aktion = ergebnis.aktion - result = exec_dialog(msg) - return result == YES + # ----------------------------------------------------- + # Allgemeine Aktionen + # ----------------------------------------------------- - def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: - pfad, _ = QFileDialog.getSaveFileName( - parent=None, - caption=titel, - directory=self.plugin_pfad or "", - filter=filter - ) - return pfad + if aktion == "leer": + warning("Eingabe fehlt", ergebnis.meldung) + return ergebnis + + if aktion == "leereingabe_erlaubt": + if ask_yes_no("Ohne Eingabe fortfahren", ergebnis.meldung): + return pruef_ergebnis(True, "Ohne Eingabe fortgefahren.", "ok", None) + return ergebnis + + if aktion == "leereingabe_nicht_erlaubt": + warning("Eingabe erforderlich", ergebnis.meldung) + return ergebnis + + if aktion == "standarddatei_vorschlagen": + if ask_yes_no("Standarddatei verwenden", ergebnis.meldung): + return pruef_ergebnis(True, "Standarddatei wird verwendet.", "ok", ergebnis.pfad) + return ergebnis + + if aktion == "temporaer_erlaubt": + if ask_yes_no("Temporäre Datei erzeugen", ergebnis.meldung): + return pruef_ergebnis(True, "Temporäre Datei soll erzeugt werden.", "temporaer_erzeugen", None) + return ergebnis + + if aktion == "datei_nicht_gefunden": + warning("Datei nicht gefunden", ergebnis.meldung) + return ergebnis + + if aktion == "kein_dateipfad": + warning("Ungültiger Pfad", ergebnis.meldung) + return ergebnis + + if aktion == "pfad_nicht_gefunden": + warning("Pfad nicht gefunden", ergebnis.meldung) + return ergebnis + + if aktion == "url_nicht_erreichbar": + warning("URL nicht erreichbar", ergebnis.meldung) + return ergebnis + + if aktion == "netzwerkfehler": + error("Netzwerkfehler", ergebnis.meldung) + return ergebnis + + # ----------------------------------------------------- + # Layer-Aktionen + # ----------------------------------------------------- + + if aktion == "layer_nicht_gefunden": + error("Layer fehlt", ergebnis.meldung) + return ergebnis + + if aktion == "layer_unsichtbar": + if ask_yes_no("Layer einblenden", ergebnis.meldung): + # Falls set_layer_visible implementiert ist + try: + set_layer_visible(ergebnis.pfad, True) + except Exception: + pass + return pruef_ergebnis(True, "Layer wurde eingeblendet.", "ok", ergebnis.pfad) + return ergebnis + + if aktion == "falscher_geotyp": + warning("Falscher Geometrietyp", ergebnis.meldung) + return ergebnis + + if aktion == "layer_leer": + warning("Layer enthält keine Objekte", ergebnis.meldung) + return ergebnis + + if aktion == "falscher_layertyp": + warning("Falscher Layertyp", ergebnis.meldung) + return ergebnis + + if aktion == "falsches_crs": + warning("Falsches CRS", ergebnis.meldung) + return ergebnis + + if aktion == "felder_fehlen": + warning("Fehlende Felder", ergebnis.meldung) + return ergebnis + + if aktion == "datenquelle_unerwartet": + warning("Unerwartete Datenquelle", ergebnis.meldung) + return ergebnis + + if aktion == "layer_nicht_editierbar": + warning("Layer nicht editierbar", ergebnis.meldung) + return ergebnis + + # ----------------------------------------------------- + # Fallback + # ----------------------------------------------------- + + warning("Unbekannte Aktion", f"Unbekannte Aktion: {aktion}") + return ergebnis diff --git a/modules/layerpruefer.py b/modules/layerpruefer.py new file mode 100644 index 0000000..b0d5c56 --- /dev/null +++ b/modules/layerpruefer.py @@ -0,0 +1,170 @@ +""" +sn_basis/modules/layerpruefer.py – Prüfung von QGIS-Layern. +Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + layer_exists, + get_layer_geometry_type, + get_layer_feature_count, + is_layer_visible, + get_layer_type, + get_layer_crs, + get_layer_fields, + get_layer_source, + is_layer_editable, +) + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +class Layerpruefer: + """ + Prüft Layer auf Existenz, Sichtbarkeit, Geometrietyp, Objektanzahl, + Layertyp, CRS, Felder, Datenquelle und Editierbarkeit. + """ + + def __init__( + self, + layer, + erwarteter_geotyp: str | None = None, + muss_sichtbar_sein: bool = False, + erwarteter_layertyp: str | None = None, + erwartetes_crs: str | None = None, + erforderliche_felder: list[str] | None = None, + erlaubte_datenquellen: list[str] | None = None, + muss_editierbar_sein: bool = False, + ): + self.layer = layer + self.erwarteter_geotyp = erwarteter_geotyp + self.muss_sichtbar_sein = muss_sichtbar_sein + self.erwarteter_layertyp = erwarteter_layertyp + self.erwartetes_crs = erwartetes_crs + self.erforderliche_felder = erforderliche_felder or [] + self.erlaubte_datenquellen = erlaubte_datenquellen or [] + self.muss_editierbar_sein = muss_editierbar_sein + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + + # ----------------------------------------------------- + # 1. Existenz + # ----------------------------------------------------- + if not layer_exists(self.layer): + return pruef_ergebnis( + ok=False, + meldung="Der Layer existiert nicht oder wurde nicht geladen.", + aktion="layer_nicht_gefunden", # type: ignore + pfad=None, + ) + + # ----------------------------------------------------- + # 2. Sichtbarkeit + # ----------------------------------------------------- + sichtbar = is_layer_visible(self.layer) + if self.muss_sichtbar_sein and not sichtbar: + return pruef_ergebnis( + ok=False, + meldung="Der Layer ist unsichtbar. Soll er eingeblendet werden?", + aktion="layer_unsichtbar", # type: ignore + pfad=self.layer, # Layerobjekt wird übergeben + ) + + # ----------------------------------------------------- + # 3. Layertyp + # ----------------------------------------------------- + layertyp = get_layer_type(self.layer) + if self.erwarteter_layertyp and layertyp != self.erwarteter_layertyp: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat den Typ '{layertyp}', erwartet wurde '{self.erwarteter_layertyp}'.", + aktion="falscher_layertyp", + pfad=None, + ) + + # ----------------------------------------------------- + # 4. Geometrietyp + # ----------------------------------------------------- + geotyp = get_layer_geometry_type(self.layer) + if self.erwarteter_geotyp and geotyp != self.erwarteter_geotyp: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat den Geometrietyp '{geotyp}', erwartet wurde '{self.erwarteter_geotyp}'.", + aktion="falscher_geotyp", + pfad=None, + ) + + # ----------------------------------------------------- + # 5. Featureanzahl + # ----------------------------------------------------- + anzahl = get_layer_feature_count(self.layer) + if anzahl == 0: + return pruef_ergebnis( + ok=False, + meldung="Der Layer enthält keine Objekte.", + aktion="layer_leer", + pfad=None, + ) + + # ----------------------------------------------------- + # 6. CRS + # ----------------------------------------------------- + crs = get_layer_crs(self.layer) + if self.erwartetes_crs and crs != self.erwartetes_crs: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat das CRS '{crs}', erwartet wurde '{self.erwartetes_crs}'.", + aktion="falsches_crs", + pfad=None, + ) + + # ----------------------------------------------------- + # 7. Felder + # ----------------------------------------------------- + felder = get_layer_fields(self.layer) + fehlende = [f for f in self.erforderliche_felder if f not in felder] + + if fehlende: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer enthält nicht alle erforderlichen Felder: {', '.join(fehlende)}", + aktion="felder_fehlen", + pfad=None, + ) + + # ----------------------------------------------------- + # 8. Datenquelle + # ----------------------------------------------------- + quelle = get_layer_source(self.layer) + if self.erlaubte_datenquellen and quelle not in self.erlaubte_datenquellen: + return pruef_ergebnis( + ok=False, + meldung=f"Die Datenquelle '{quelle}' ist nicht erlaubt.", + aktion="datenquelle_unerwartet", + pfad=None, + ) + + # ----------------------------------------------------- + # 9. Editierbarkeit + # ----------------------------------------------------- + editable = is_layer_editable(self.layer) + if self.muss_editierbar_sein and not editable: + return pruef_ergebnis( + ok=False, + meldung="Der Layer ist nicht editierbar.", + aktion="layer_nicht_editierbar", + pfad=None, + ) + + # ----------------------------------------------------- + # 10. Alles OK + # ----------------------------------------------------- + return pruef_ergebnis( + ok=True, + meldung="Layerprüfung erfolgreich.", + aktion="ok", + pfad=None, + ) diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index 9283c85..0b15889 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -1,77 +1,141 @@ -# Linkpruefer.py – Qt5/Qt6-kompatibel über qt_compat +""" +sn_basis/modules/linkpruefer.py – Prüfung von URLs und lokalen Links. +Verwendet syswrapper und qgisqt_wrapper. +Gibt pruef_ergebnis an den Pruefmanager zurück. +""" -from modules.qt_compat import ( - QEventLoop, - QUrl, - QNetworkRequest, - QNetworkReply +from sn_basis.functions.syswrapper import ( + file_exists, + is_file, + join_path, ) -from qgis.core import QgsNetworkAccessManager -from modules.pruef_ergebnis import PruefErgebnis +from sn_basis.functions.qgisqt_wrapper import ( + network_head, +) + +from sn_basis.modules.Pruefmanager import pruef_ergebnis class Linkpruefer: - """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" + """ + Prüft URLs und lokale Pfade. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + """ - ANBIETER_TYPEN: dict[str, str] = { - "REST": "REST", - "WFS": "WFS", - "WMS": "WMS", - "OGR": "OGR" - } + def __init__(self, basis_pfad: str | None = None): + """ + basis_pfad: optionaler Basisordner für relative Pfade. + """ + self.basis = basis_pfad - def __init__(self, link: str, anbieter: str): - self.link = link - self.anbieter = anbieter.upper().strip() if anbieter else "" - self.network_manager = QgsNetworkAccessManager() + # --------------------------------------------------------- + # Hilfsfunktionen + # --------------------------------------------------------- - def klassifiziere_anbieter(self): - typ = self.ANBIETER_TYPEN.get(self.anbieter, self.anbieter) - quelle = "remote" if self.link.startswith(("http://", "https://")) else "local" - return {"typ": typ, "quelle": quelle} + def _pfad(self, relativer_pfad: str) -> str: + """Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis.""" + if not self.basis: + return relativer_pfad + return join_path(self.basis, relativer_pfad) - def pruefe_link(self): - fehler = [] - warnungen = [] + def _ist_url(self, text: str) -> bool: + """Einfache URL-Erkennung.""" + return text.startswith("http://") or text.startswith("https://") - if not self.link: - fehler.append("Link fehlt.") - return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- - if not self.anbieter or not self.anbieter.strip(): - fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") + def pruefe(self, eingabe: str) -> pruef_ergebnis: + """ + Prüft einen Link (URL oder lokalen Pfad). + Rückgabe: pruef_ergebnis + """ - # Remote-Links prüfen - if self.link.startswith(("http://", "https://")): - request = QNetworkRequest(QUrl(self.link)) - reply = self.network_manager.head(request) + if not eingabe: + return pruef_ergebnis( + ok=False, + meldung="Es wurde kein Link angegeben.", + aktion="leer", + pfad=None, + ) - loop = QEventLoop() - reply.finished.connect(loop.quit) - loop.exec() # Qt5/Qt6-kompatibel über qt_compat + # ----------------------------------------------------- + # 1. Fall: URL + # ----------------------------------------------------- + if self._ist_url(eingabe): + return self._pruefe_url(eingabe) - # Fehlerprüfung Qt5/Qt6-kompatibel - if reply.error() != QNetworkReply.NetworkError.NoError: - fehler.append(f"Verbindungsfehler: {reply.errorString()}") - else: - status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status is None or status < 200 or status >= 400: - fehler.append(f"Link nicht erreichbar: HTTP {status}") + # ----------------------------------------------------- + # 2. Fall: lokaler Pfad + # ----------------------------------------------------- + return self._pruefe_dateipfad(eingabe) - reply.deleteLater() + # --------------------------------------------------------- + # URL‑Prüfung + # --------------------------------------------------------- - else: - # Lokale Pfade: Plausibilitätscheck - if "." not in self.link.split("/")[-1]: - warnungen.append("Der lokale Link sieht ungewöhnlich aus.") + def _pruefe_url(self, url: str) -> pruef_ergebnis: + """ + Prüft eine URL über einen HEAD‑Request. + """ - return PruefErgebnis( - len(fehler) == 0, - daten=self.klassifiziere_anbieter(), - fehler=fehler, - warnungen=warnungen + reply = network_head(url) + + if reply is None: + return pruef_ergebnis( + ok=False, + meldung=f"Die URL '{url}' konnte nicht geprüft werden.", + aktion="netzwerkfehler", + pfad=url, + ) + + if reply.error != 0: + return pruef_ergebnis( + ok=False, + meldung=f"Die URL '{url}' ist nicht erreichbar.", + aktion="url_nicht_erreichbar", + pfad=url, + ) + + return pruef_ergebnis( + ok=True, + meldung="URL ist erreichbar.", + aktion="ok", + pfad=url, ) - def ausfuehren(self): - return self.pruefe_link() + # --------------------------------------------------------- + # Lokale Datei‑/Pfadprüfung + # --------------------------------------------------------- + + def _pruefe_dateipfad(self, eingabe: str) -> pruef_ergebnis: + """ + Prüft einen lokalen Pfad. + """ + + pfad = self._pfad(eingabe) + + if not file_exists(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Der Pfad '{eingabe}' wurde nicht gefunden.", + aktion="pfad_nicht_gefunden", + pfad=pfad, + ) + + if not is_file(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Der Pfad '{eingabe}' ist keine Datei.", + aktion="kein_dateipfad", + pfad=pfad, + ) + + return pruef_ergebnis( + ok=True, + meldung="Dateipfad ist gültig.", + aktion="ok", + pfad=pfad, + ) diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index 54ecda4..e15d8ad 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,12 +1,58 @@ -#pruef_ergebnis.py -# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann -class PruefErgebnis: - def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): - self.erfolgreich = erfolgreich - self.daten = daten or {} - self.fehler = fehler or [] - self.warnungen = warnungen or [] +""" +sn_basis/modules/pruef_ergebnis.py – Ergebnisobjekt für alle Prüfer. - def __repr__(self): - return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " - f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") +""" + +from dataclasses import dataclass +from typing import Optional, Literal + + +# Alle möglichen Aktionen, die ein Prüfer auslösen kann. +# Erweiterbar ohne Umbau der Klasse. +PruefAktion = Literal[ + "ok", + "leer", + "leereingabe_erlaubt", + "leereingabe_nicht_erlaubt", + "standarddatei_vorschlagen", + "temporaer_erlaubt", + "datei_nicht_gefunden", + "kein_dateipfad", + "pfad_nicht_gefunden", + "url_nicht_erreichbar", + "netzwerkfehler", + "falscher_layertyp", + "falscher_geotyp", + "layer_leer", + "falsches_crs", + "felder_fehlen", + "datenquelle_unerwartet", + "layer_nicht_editierbar", + "temporaer_erzeugen", + "stil_nicht_anwendbar", + "layer_unsichtbar", + "unbekannt", +] + + +@dataclass +class pruef_ergebnis: + """ + Reines Datenobjekt, das das Ergebnis einer Prüfung beschreibt. + + ok: True → Prüfung erfolgreich + False → Nutzerinteraktion oder Fehler nötig + + meldung: Text, der dem Nutzer angezeigt werden soll + + aktion: Maschinenlesbarer Code, der dem Pruefmanager sagt, + wie er weiter verfahren soll + + pfad: Optionaler Pfad oder URL, die geprüft wurde oder + verwendet werden soll + """ + + ok: bool + meldung: str + aktion: PruefAktion + pfad: Optional[str] = None diff --git a/modules/qt_compat.py b/modules/qt_compat.py deleted file mode 100644 index dca7495..0000000 --- a/modules/qt_compat.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -qt_compat.py – Einheitliche Qt-Kompatibilitätsschicht für QGIS-Plugins. - -Ziele: -- PyQt6 bevorzugt -- Fallback auf PyQt5 -- Mock-Modus, wenn kein Qt verfügbar ist (z. B. in Unittests) -- OR-fähige Fake-Enums im Mock-Modus -""" - -QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 - -# --------------------------------------------------------- -# Versuch: PyQt6 importieren -# --------------------------------------------------------- -try: - from PyQt6.QtWidgets import QMessageBox, QFileDialog - from PyQt6.QtCore import Qt, QEventLoop, QUrl - from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply - - YES = QMessageBox.StandardButton.Yes - NO = QMessageBox.StandardButton.No - CANCEL = QMessageBox.StandardButton.Cancel - ICON_QUESTION = QMessageBox.Icon.Question - - QT_VERSION = 6 - - def exec_dialog(dialog): - """Einheitliche Ausführung eines Dialogs.""" - return dialog.exec() - -# --------------------------------------------------------- -# Versuch: PyQt5 importieren -# --------------------------------------------------------- -except Exception: - try: - from PyQt5.QtWidgets import QMessageBox, QFileDialog - from PyQt5.QtCore import Qt, QEventLoop, QUrl - from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply - - YES = QMessageBox.Yes - NO = QMessageBox.No - CANCEL = QMessageBox.Cancel - ICON_QUESTION = QMessageBox.Question - - QT_VERSION = 5 - - def exec_dialog(dialog): - return dialog.exec_() - - # --------------------------------------------------------- - # Mock-Modus (kein Qt verfügbar) - # --------------------------------------------------------- - except Exception: - QT_VERSION = 0 - - class FakeEnum(int): - """Ein OR-fähiger Enum-Ersatz für den Mock-Modus.""" - def __or__(self, other): - return FakeEnum(int(self) | int(other)) - - class QMessageBox: - Yes = FakeEnum(1) - No = FakeEnum(2) - Cancel = FakeEnum(4) - Question = FakeEnum(8) - - class QFileDialog: - """Minimaler Mock für QFileDialog.""" - @staticmethod - def getOpenFileName(*args, **kwargs): - return ("", "") # kein Dateipfad - - YES = QMessageBox.Yes - NO = QMessageBox.No - CANCEL = QMessageBox.Cancel - ICON_QUESTION = QMessageBox.Question - - def exec_dialog(dialog): - """Mock-Ausführung: gibt YES zurück, außer Tests patchen es.""" - return YES - # ------------------------- - # Mock Netzwerk-Klassen - # ------------------------- - class QEventLoop: - def exec(self): - return 0 - - def quit(self): - pass - - class QUrl(str): - pass - - class QNetworkRequest: - def __init__(self, url): - self.url = url - - class QNetworkReply: - def __init__(self): - self._data = b"" - - def readAll(self): - return self._data - - def error(self): - return 0 - - def exec_dialog(dialog): - return YES - \ No newline at end of file diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py index dc43f4d..43734f2 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -1,46 +1,59 @@ -#stilpruefer.py -import os -from modules.pruef_ergebnis import PruefErgebnis +""" +sn_basis/modules/stilpruefer.py – Prüfung und Anwendung von Layerstilen. +Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + apply_style, +) + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class Stilpruefer: """ - Prüft, ob ein angegebener Stilpfad gültig und nutzbar ist. - - Wenn kein Stil angegeben ist, gilt die Prüfung als erfolgreich. - - Wenn angegeben: - * Datei muss existieren - * Dateiendung muss '.qml' sein + Prüft, ob ein Stil auf einen Layer angewendet werden kann. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. """ - def pruefe(self, stilpfad: str) -> PruefErgebnis: - # kein Stil angegeben -> erfolgreich, keine Warnung - if not stilpfad or stilpfad.strip() == "": - return PruefErgebnis( - erfolgreich=True, - daten={"stil": None}, - warnungen=["Kein Stil angegeben."] + def __init__(self, layer, stil_pfad: str): + """ + layer: QGIS-Layer oder Mock-Layer + stil_pfad: relativer oder absoluter Pfad zum .qml-Stil + """ + self.layer = layer + self.stil_pfad = stil_pfad + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + """ + Versucht, den Stil anzuwenden. + Rückgabe: pruef_ergebnis + """ + + # Wrapper übernimmt: + # - Pfadberechnung + # - Existenzprüfung + # - loadNamedStyle + # - Fehlerbehandlung + # - Mock-Modus + erfolg, meldung = apply_style(self.layer, self.stil_pfad) + + if erfolg: + return pruef_ergebnis( + ok=True, + meldung=f"Stil erfolgreich angewendet: {self.stil_pfad}", + aktion="ok", + pfad=self.stil_pfad, ) - fehler = [] - warnungen = [] - - # Prüfung: Datei existiert? - if not os.path.exists(stilpfad): - fehler.append(f"Stildatei nicht gefunden: {stilpfad}") - - # Prüfung: Endung .qml? - elif not stilpfad.lower().endswith(".qml"): - fehler.append(f"Ungültige Dateiendung für Stil: {stilpfad}") - else: - # Hinweis: alle Checks bestanden - return PruefErgebnis( - erfolgreich=True, - daten={"stil": stilpfad} - ) - - return PruefErgebnis( - erfolgreich=False if fehler else True, - daten={"stil": stilpfad}, - fehler=fehler, - warnungen=warnungen + # Fehlerfall → Nutzerinteraktion nötig + return pruef_ergebnis( + ok=False, + meldung=meldung, + aktion="stil_nicht_anwendbar", + pfad=self.stil_pfad, ) diff --git a/test/run_tests.py b/test/run_tests.py index a0ec181..7c2b03a 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -1,11 +1,49 @@ -#run_tests.py -import sys -import os +""" +sn_basis/test/run_tests.py + +Zentraler Test-Runner für sn_basis. +Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig. +""" + import unittest import datetime import inspect +import os +import sys + + + +# Minimaler Bootstrap, um sn_basis importierbar zu machen +TEST_DIR = os.path.dirname(__file__) +PLUGIN_ROOT = os.path.abspath(os.path.join(TEST_DIR, "..", "..")) + +if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + + +from sn_basis.functions import syswrapper + +# --------------------------------------------------------- +# Bootstrap: Plugin-Root in sys.path eintragen +# --------------------------------------------------------- + +def bootstrap(): + """ + Simuliert das QGIS-Plugin-Startverhalten: + stellt sicher, dass sn_basis importierbar ist. + """ + plugin_root = syswrapper.get_plugin_root() + syswrapper.add_to_sys_path(plugin_root) + + +bootstrap() + + +# --------------------------------------------------------- # Farben +# --------------------------------------------------------- + RED = "\033[91m" YELLOW = "\033[93m" GREEN = "\033[92m" @@ -13,36 +51,30 @@ CYAN = "\033[96m" MAGENTA = "\033[95m" RESET = "\033[0m" -# Globaler Testzähler GLOBAL_TEST_COUNTER = 0 # --------------------------------------------------------- -# Eigene TestResult-Klasse (färbt Fehler/Skipped/OK) +# Farbige TestResult-Klasse # --------------------------------------------------------- + class ColoredTestResult(unittest.TextTestResult): def startTest(self, test): - """Vor jedem Test eine Nummer ausgeben.""" global GLOBAL_TEST_COUNTER GLOBAL_TEST_COUNTER += 1 self.stream.write(f"{CYAN}[Test {GLOBAL_TEST_COUNTER}]{RESET}\n") super().startTest(test) - def startTestRun(self): - """Wird einmal zu Beginn des gesamten Testlaufs ausgeführt.""" - super().startTestRun() - def startTestClass(self, test): - """Wird aufgerufen, wenn eine neue Testklasse beginnt.""" cls = test.__class__ file = inspect.getfile(cls) filename = os.path.basename(file) self.stream.write( - f"\n{MAGENTA}{'='*70}\n" + f"\n{MAGENTA}{'=' * 70}\n" f"Starte Testklasse: {filename} → {cls.__name__}\n" - f"{'='*70}{RESET}\n" + f"{'=' * 70}{RESET}\n" ) def addError(self, test, err): @@ -57,31 +89,27 @@ class ColoredTestResult(unittest.TextTestResult): super().addSkip(test, reason) self.stream.write(f"{YELLOW}SKIPPED{RESET}: {reason}\n") - # unittest ruft diese Methode nicht automatisch auf → wir patchen es unten def addSuccess(self, test): super().addSuccess(test) self.stream.write(f"{GREEN}OK{RESET}\n") # --------------------------------------------------------- -# Eigener TestRunner, der unser ColoredTestResult nutzt +# Farbiger TestRunner # --------------------------------------------------------- + class ColoredTestRunner(unittest.TextTestRunner): resultclass = ColoredTestResult def _makeResult(self): result = super()._makeResult() - - # Patch: unittest ruft startTestClass nicht automatisch auf original_start_test = result.startTest def patched_start_test(test): - # Wenn neue Klasse → Kopf ausgeben if not hasattr(result, "_last_test_class") or \ result._last_test_class != test.__class__: result.startTestClass(test) result._last_test_class = test.__class__ - original_start_test(test) result.startTest = patched_start_test @@ -89,37 +117,30 @@ class ColoredTestRunner(unittest.TextTestRunner): # --------------------------------------------------------- -# Testlauf starten +# Testlauf starten # --------------------------------------------------------- -print("\n" + "="*70) -print(f"{CYAN}Testlauf gestartet am: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}") -print("="*70 + "\n") - -# Projekt-Root dem Suchpfad hinzufügen -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -if project_root not in sys.path: - sys.path.insert(0, project_root) - def main(): + print("\n" + "=" * 70) + print( + f"{CYAN}Testlauf gestartet am: " + f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}" + ) + print("=" * 70 + "\n") + loader = unittest.TestLoader() - suite = unittest.TestSuite() - test_modules = [ - "test_dateipruefer", - "test_stilpruefer", - "test_linkpruefer", - "test_qt_compat", - "test_pruefmanager", - ] - - for mod_name in test_modules: - mod = __import__(mod_name) - suite.addTests(loader.loadTestsFromModule(mod)) + suite = loader.discover( + start_dir=os.path.dirname(__file__), + pattern="test_*.py" + ) runner = ColoredTestRunner(verbosity=2) - runner.run(suite) + result = runner.run(suite) + + # Exit-Code für CI / Skripte + return 0 if result.wasSuccessful() else 1 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py new file mode 100644 index 0000000..10db7cd --- /dev/null +++ b/test/test_bootstrap.py @@ -0,0 +1,2 @@ +from sn_basis.functions import syswrapper +syswrapper.add_to_sys_path(syswrapper.get_plugin_root()) diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py index f6f537b..d61ac70 100644 --- a/test/test_dateipruefer.py +++ b/test/test_dateipruefer.py @@ -1,89 +1,102 @@ -#test_dateipruefer.py +# sn_basis/test/test_dateipruefer.py + import unittest -import os -import tempfile -import sys -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) -from modules.Dateipruefer import ( - Dateipruefer, - LeererPfadModus, - DateiEntscheidung, - DateipruefErgebnis -) +from unittest.mock import patch + +from sn_basis.modules.Dateipruefer import Dateipruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + class TestDateipruefer(unittest.TestCase): - def setUp(self): - self.pruefer = Dateipruefer() - self.plugin_pfad = tempfile.gettempdir() - self.standardname = "test_standard.gpkg" - def test_verbotener_leerer_pfad(self): - result = self.pruefer.pruefe( + # ----------------------------------------------------- + # 1. Leere Eingabe erlaubt + # ----------------------------------------------------- + def test_leereingabe_erlaubt(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.VERBOTEN + leereingabe_erlaubt=True ) - self.assertFalse(result.erfolgreich) - self.assertIn("Kein Pfad angegeben.", result.fehler) - def test_standardpfad_wird_verwendet(self): - result = self.pruefer.pruefe( + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "leereingabe_erlaubt") + + # ----------------------------------------------------- + # 2. Leere Eingabe nicht erlaubt + # ----------------------------------------------------- + def test_leereingabe_nicht_erlaubt(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.NUTZE_STANDARD, - standardname=self.standardname, - plugin_pfad=self.plugin_pfad + leereingabe_erlaubt=False ) - self.assertTrue(result.erfolgreich) - expected_path = os.path.join(self.plugin_pfad, self.standardname) - self.assertEqual(result.pfad, expected_path) - def test_temporärer_layer_wird_erkannt(self): - result = self.pruefer.pruefe( + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "leereingabe_nicht_erlaubt") + + # ----------------------------------------------------- + # 3. Standarddatei vorschlagen + # ----------------------------------------------------- + def test_standarddatei_vorschlagen(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.TEMPORAER_ERLAUBT + standarddatei="/tmp/std.txt" ) - self.assertTrue(result.erfolgreich) - self.assertIsNone(result.pfad) - self.assertFalse(result.temporär) - def test_existierende_datei_ohne_entscheidung(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_path = tmp_file.name - try: - result = self.pruefer.pruefe( - pfad=tmp_path, - leer_modus=LeererPfadModus.VERBOTEN - ) - self.assertTrue(result.erfolgreich) # neu: jetzt True, nicht False - self.assertIn("Datei existiert bereits – Entscheidung ausstehend.", result.fehler) - self.assertIsNone(result.entscheidung) - finally: - os.remove(tmp_path) + result = pruefer.pruefe() - def test_existierende_datei_mit_entscheidung_ersetzen(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_path = tmp_file.name - try: - result = self.pruefer.pruefe( - pfad=tmp_path, - leer_modus=LeererPfadModus.VERBOTEN, - vorhandene_datei_entscheidung=DateiEntscheidung.ERSETZEN - ) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.entscheidung, DateiEntscheidung.ERSETZEN) - finally: - os.remove(tmp_path) + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "standarddatei_vorschlagen") + self.assertEqual(result.pfad, "/tmp/std.txt") - def test_datei_nicht_existiert(self): - fake_path = os.path.join(self.plugin_pfad, "nicht_existierend.gpkg") - result = self.pruefer.pruefe( - pfad=fake_path, - leer_modus=LeererPfadModus.VERBOTEN + # ----------------------------------------------------- + # 4. Temporäre Datei erlaubt + # ----------------------------------------------------- + def test_temporaer_erlaubt(self): + pruefer = Dateipruefer( + pfad="", + temporaer_erlaubt=True ) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.pfad, fake_path) + + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "temporaer_erlaubt") + + # ----------------------------------------------------- + # 5. Datei existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + def test_datei_nicht_gefunden(self, mock_exists): + pruefer = Dateipruefer( + pfad="/tmp/nichtvorhanden.txt" + ) + + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "datei_nicht_gefunden") + + # ----------------------------------------------------- + # 6. Datei existiert + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_ok(self, mock_isfile, mock_exists): + pruefer = Dateipruefer( + pfad="/tmp/test.txt" + ) + + result = pruefer.pruefe() + + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertEqual(result.pfad, "/tmp/test.txt") if __name__ == "__main__": diff --git a/test/test_layerpruefer.py b/test/test_layerpruefer.py new file mode 100644 index 0000000..46bde78 --- /dev/null +++ b/test/test_layerpruefer.py @@ -0,0 +1,170 @@ +# sn_basis/test/test_layerpruefer.py + +import unittest + +from sn_basis.modules.layerpruefer import Layerpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +# --------------------------------------------------------- +# Mock-Layer für Wrapper-Tests +# --------------------------------------------------------- +class MockLayer: + def __init__( + self, + exists=True, + visible=True, + layer_type="vector", + geometry_type="Polygon", + feature_count=10, + crs="EPSG:25833", + fields=None, + source="/tmp/test.shp", + editable=True, + ): + self.exists = exists + self.visible = visible + self.layer_type = layer_type + self.geometry_type = geometry_type + self.feature_count = feature_count + self.crs = crs + self.fields = fields or [] + self.source = source + self.editable = editable + + +# --------------------------------------------------------- +# Wrapper-Mocks (monkeypatching) +# --------------------------------------------------------- +def mock_layer_exists(layer): + return layer is not None and layer.exists + + +def mock_is_layer_visible(layer): + return layer.visible + + +def mock_get_layer_type(layer): + return layer.layer_type + + +def mock_get_layer_geometry_type(layer): + return layer.geometry_type + + +def mock_get_layer_feature_count(layer): + return layer.feature_count + + +def mock_get_layer_crs(layer): + return layer.crs + + +def mock_get_layer_fields(layer): + return layer.fields + + +def mock_get_layer_source(layer): + return layer.source + + +def mock_is_layer_editable(layer): + return layer.editable + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- +class TestLayerpruefer(unittest.TestCase): + + def setUp(self): + # Monkeypatching der Wrapper-Funktionen + import sn_basis.functions.qgisqt_wrapper as wrapper + + wrapper.layer_exists = mock_layer_exists + wrapper.is_layer_visible = mock_is_layer_visible + wrapper.get_layer_type = mock_get_layer_type + wrapper.get_layer_geometry_type = mock_get_layer_geometry_type + wrapper.get_layer_feature_count = mock_get_layer_feature_count + wrapper.get_layer_crs = mock_get_layer_crs + wrapper.get_layer_fields = mock_get_layer_fields + wrapper.get_layer_source = mock_get_layer_source + wrapper.is_layer_editable = mock_is_layer_editable + + # ----------------------------------------------------- + # Tests + # ----------------------------------------------------- + + def test_layer_exists(self): + layer = MockLayer(exists=False) + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_nicht_gefunden") + + def test_layer_unsichtbar(self): + layer = MockLayer(visible=False) + pruefer = Layerpruefer(layer, muss_sichtbar_sein=True) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_unsichtbar") + + def test_falscher_layertyp(self): + layer = MockLayer(layer_type="raster") + pruefer = Layerpruefer(layer, erwarteter_layertyp="vector") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falscher_layertyp") + + def test_falscher_geotyp(self): + layer = MockLayer(geometry_type="Point") + pruefer = Layerpruefer(layer, erwarteter_geotyp="Polygon") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falscher_geotyp") + + def test_layer_leer(self): + layer = MockLayer(feature_count=0) + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_leer") + + def test_falsches_crs(self): + layer = MockLayer(crs="EPSG:4326") + pruefer = Layerpruefer(layer, erwartetes_crs="EPSG:25833") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falsches_crs") + + def test_felder_fehlen(self): + layer = MockLayer(fields=["id"]) + pruefer = Layerpruefer(layer, erforderliche_felder=["id", "name"]) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "felder_fehlen") + + def test_datenquelle_unerwartet(self): + layer = MockLayer(source="/tmp/test.shp") + pruefer = Layerpruefer(layer, erlaubte_datenquellen=["/tmp/allowed.shp"]) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "datenquelle_unerwartet") + + def test_layer_nicht_editierbar(self): + layer = MockLayer(editable=False) + pruefer = Layerpruefer(layer, muss_editierbar_sein=True) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_nicht_editierbar") + + def test_layer_ok(self): + layer = MockLayer() + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertTrue(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py index d9d4206..89ea0e3 100644 --- a/test/test_linkpruefer.py +++ b/test/test_linkpruefer.py @@ -1,77 +1,107 @@ -#test_linkpruefer.py +# sn_basis/test/test_linkpruefer.py + import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch -# QGIS-Module mocken, damit der Import funktioniert -with patch.dict("sys.modules", { - "qgis": MagicMock(), - "qgis.PyQt": MagicMock(), - "qgis.PyQt.QtCore": MagicMock(), - "qgis.PyQt.QtNetwork": MagicMock(), - "qgis.core": MagicMock(), -}): - from modules.linkpruefer import Linkpruefer +from sn_basis.modules.linkpruefer import Linkpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +# --------------------------------------------------------- +# Mock-Ergebnisse für network_head() +# --------------------------------------------------------- + +class MockResponseOK: + ok = True + status = 200 + error = None + + +class MockResponseNotFound: + ok = False + status = 404 + error = "Not Found" + + +class MockResponseConnectionError: + ok = False + status = None + error = "Connection refused" + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- + class TestLinkpruefer(unittest.TestCase): - @patch("modules.linkpruefer.QNetworkReply") - @patch("modules.linkpruefer.QNetworkRequest") - @patch("modules.linkpruefer.QUrl") - @patch("modules.linkpruefer.QEventLoop") - @patch("modules.linkpruefer.QgsNetworkAccessManager") - def test_remote_link_ok( - self, mock_manager, mock_loop, mock_url, mock_request, mock_reply - ): - # Setup: simulate successful HEAD request - reply_instance = MagicMock() - reply_instance.error.return_value = mock_reply.NetworkError.NoError - reply_instance.attribute.return_value = 200 - - mock_manager.return_value.head.return_value = reply_instance + # ----------------------------------------------------- + # 1. Remote-Link erreichbar + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_ok(self, mock_head): + mock_head.return_value = MockResponseOK() lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten["quelle"], "remote") + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") - @patch("modules.linkpruefer.QNetworkReply") - @patch("modules.linkpruefer.QNetworkRequest") - @patch("modules.linkpruefer.QUrl") - @patch("modules.linkpruefer.QEventLoop") - @patch("modules.linkpruefer.QgsNetworkAccessManager") - def test_remote_link_error( - self, mock_manager, mock_loop, mock_url, mock_request, mock_reply - ): - # Fake-Reply erzeugen - reply_instance = MagicMock() - reply_instance.error.return_value = mock_reply.NetworkError.ConnectionRefusedError - reply_instance.errorString.return_value = "Connection refused" - - # WICHTIG: finished-Signal simulieren - reply_instance.finished = MagicMock() - reply_instance.finished.connect = MagicMock() - - # Wenn loop.exec() aufgerufen wird, rufen wir loop.quit() sofort auf - mock_loop.return_value.exec.side_effect = lambda: mock_loop.return_value.quit() - - # Manager gibt unser Fake-Reply zurück - mock_manager.return_value.head.return_value = reply_instance + # ----------------------------------------------------- + # 2. Remote-Link nicht erreichbar + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_error(self, mock_head): + mock_head.return_value = MockResponseConnectionError() lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertFalse(result.erfolgreich) - self.assertIn("Verbindungsfehler", result.fehler[0]) + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "url_nicht_erreichbar") + self.assertIn("Connection refused", result.meldung) + # ----------------------------------------------------- + # 3. Remote-Link 404 + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_404(self, mock_head): + mock_head.return_value = MockResponseNotFound() + + lp = Linkpruefer("http://example.com/missing", "REST") + result = lp.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "url_nicht_erreichbar") + self.assertIn("404", result.meldung) + + # ----------------------------------------------------- + # 4. Lokaler Pfad existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists") + def test_local_link_not_found(self, mock_exists): + mock_exists.return_value = False + + lp = Linkpruefer("/path/to/missing/file.shp", "OGR") + result = lp.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "pfad_nicht_gefunden") + + # ----------------------------------------------------- + # 5. Lokaler Pfad existiert, aber ungewöhnlich + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists") + def test_local_link_warning(self, mock_exists): + mock_exists.return_value = True - def test_local_link_warning(self): lp = Linkpruefer("/path/to/file_without_extension", "OGR") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertTrue(result.erfolgreich) - self.assertIn("ungewöhnlich", result.warnungen[0]) + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertIn("ungewöhnlich", result.meldung) if __name__ == "__main__": diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py index dd23c31..3a0cfe6 100644 --- a/test/test_pruefmanager.py +++ b/test/test_pruefmanager.py @@ -1,86 +1,133 @@ -#test_pruefmanager.py +# sn_basis/test/test_pruefmanager.py + import unittest -import os -import sys -from unittest.mock import patch, MagicMock +from unittest.mock import patch -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) - -from modules.Pruefmanager import PruefManager -from modules.Dateipruefer import DateiEntscheidung -import modules.qt_compat as qt_compat +from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis -# Skip-Decorator für Mock-Modus -def skip_if_mock(reason): - return unittest.skipIf( - qt_compat.QT_VERSION == 0, - f"{reason} — MOCK-Modus erkannt. " - "Bitte diesen Test in einer echten QGIS-Umgebung ausführen." - ) - - -class TestPruefManager(unittest.TestCase): +class TestPruefmanager(unittest.TestCase): def setUp(self): - self.manager = PruefManager(plugin_pfad="/tmp") + self.manager = Pruefmanager() - # --------------------------------------------------------- - # Tests für frage_datei_ersetzen_oder_anhaengen - # --------------------------------------------------------- + # ----------------------------------------------------- + # 1. OK-Ergebnis → keine Interaktion + # ----------------------------------------------------- + def test_ok(self): + ergebnis = pruef_ergebnis(True, "Alles gut", "ok", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) - def test_frage_datei_ersetzen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) - def test_frage_datei_anhaengen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN) + # ----------------------------------------------------- + # 2. Leere Eingabe erlaubt → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_leereingabe_erlaubt_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.CANCEL) - def test_frage_datei_abbrechen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") - # --------------------------------------------------------- - # Fehlerfall: exec_dialog liefert etwas Unerwartetes - # --------------------------------------------------------- + # ----------------------------------------------------- + # 3. Leere Eingabe erlaubt → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_leereingabe_erlaubt_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=999) - def test_frage_datei_unbekannte_antwort(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "leereingabe_erlaubt") - # --------------------------------------------------------- - # Tests für frage_temporär_verwenden - # --------------------------------------------------------- + # ----------------------------------------------------- + # 4. Standarddatei vorschlagen → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_standarddatei_vorschlagen_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) - def test_frage_temporär_verwenden_ja(self, mock_exec): - self.assertTrue(self.manager.frage_temporär_verwenden()) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") + self.assertEqual(entscheidung.pfad, "/tmp/std.txt") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) - def test_frage_temporär_verwenden_nein(self, mock_exec): - self.assertFalse(self.manager.frage_temporär_verwenden()) + # ----------------------------------------------------- + # 5. Standarddatei vorschlagen → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_standarddatei_vorschlagen_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + entscheidung = self.manager.verarbeite(ergebnis) - # --------------------------------------------------------- - # Fehlerfall: exec_dialog liefert etwas Unerwartetes - # --------------------------------------------------------- + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "standarddatei_vorschlagen") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=None) - def test_frage_temporär_verwenden_unbekannt(self, mock_exec): - self.assertFalse(self.manager.frage_temporär_verwenden()) + # ----------------------------------------------------- + # 6. Temporäre Datei erzeugen → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_temporaer_erlaubt_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "temporaer_erzeugen") + + # ----------------------------------------------------- + # 7. Temporäre Datei erzeugen → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_temporaer_erlaubt_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "temporaer_erlaubt") + + # ----------------------------------------------------- + # 8. Layer unsichtbar → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + @patch("sn_basis.functions.qgisqt_wrapper.set_layer_visible") + def test_layer_unsichtbar_ja(self, mock_set, mock_ask): + fake_layer = object() + ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) + + entscheidung = self.manager.verarbeite(ergebnis) + + mock_set.assert_called_once_with(fake_layer, True) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") + + # ----------------------------------------------------- + # 9. Layer unsichtbar → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_layer_unsichtbar_nein(self, mock_ask): + fake_layer = object() + ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) + + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "layer_unsichtbar") + + # ----------------------------------------------------- + # 10. Fehlerhafte Aktion → Fallback + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.warning") + def test_unbekannte_aktion(self, mock_warn): + ergebnis = pruef_ergebnis(False, "???", "unbekannt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + mock_warn.assert_called_once() + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "unbekannt") if __name__ == "__main__": diff --git a/test/test_qt_compat.py b/test/test_qt_compat.py deleted file mode 100644 index 92bfd31..0000000 --- a/test/test_qt_compat.py +++ /dev/null @@ -1,100 +0,0 @@ -#test_qt_compat.py -import unittest -import os -import sys -from unittest.mock import MagicMock -import modules.qt_compat as qt_compat -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) - -def skip_if_mock(reason): - """Decorator: überspringt Test, wenn qt_compat im Mock-Modus läuft.""" - return unittest.skipIf( - qt_compat.QT_VERSION == 0, - f"{reason} — MOCK-Modus erkannt." - f"Bitte diesen Test in einer echten QGIS-Umgebung ausführen." - ) - - -class TestQtCompat(unittest.TestCase): - - def test_exports_exist(self): - """Prüft, ob alle erwarteten Symbole exportiert werden.""" - expected = { - "QMessageBox", - "QFileDialog", - "QEventLoop", - "QUrl", - "QNetworkRequest", - "QNetworkReply", - "YES", - "NO", - "CANCEL", - "ICON_QUESTION", - "exec_dialog", - "QT_VERSION", - } - - for symbol in expected: - self.assertTrue( - hasattr(qt_compat, symbol), - f"qt_compat sollte '{symbol}' exportieren" - ) - - @skip_if_mock("QT_VERSION kann im Mock-Modus nicht 5 oder 6 sein") - def test_qt_version_flag(self): - """QT_VERSION muss 5 oder 6 sein.""" - self.assertIn(qt_compat.QT_VERSION, (5, 6)) - - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - def test_enums_are_valid(self): - """Prüft, ob die Enums gültige QMessageBox-Werte sind.""" - - msg = qt_compat.QMessageBox() - try: - msg.setStandardButtons( - qt_compat.YES | - qt_compat.NO | - qt_compat.CANCEL - ) - except Exception as e: - self.fail(f"Qt-Enums sollten OR-kombinierbar sein, Fehler: {e}") - - self.assertTrue(True) - - @skip_if_mock("exec_dialog benötigt echtes Qt-Verhalten") - def test_exec_dialog_calls_correct_method(self): - """Prüft, ob exec_dialog() die richtige Methode aufruft.""" - - mock_msg = MagicMock() - - if qt_compat.QT_VERSION == 6: - qt_compat.exec_dialog(mock_msg) - mock_msg.exec.assert_called_once() - - elif qt_compat.QT_VERSION == 5: - qt_compat.exec_dialog(mock_msg) - mock_msg.exec_.assert_called_once() - - else: - self.fail("QT_VERSION hat einen unerwarteten Wert.") - - @skip_if_mock("Qt-Klassen können im Mock-Modus nicht real instanziiert werden") - def test_qt_classes_importable(self): - """Prüft, ob die wichtigsten Qt-Klassen instanziierbar sind.""" - - loop = qt_compat.QEventLoop() - self.assertIsNotNone(loop) - - url = qt_compat.QUrl("http://example.com") - self.assertTrue(url.isValid()) - - req = qt_compat.QNetworkRequest(url) - self.assertIsNotNone(req) - - self.assertTrue(hasattr(qt_compat.QNetworkReply, "NetworkError")) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_settings_logic.py b/test/test_settings_logic.py new file mode 100644 index 0000000..b360bb1 --- /dev/null +++ b/test/test_settings_logic.py @@ -0,0 +1,60 @@ +# sn_basis/test/test_settings_logic.py + +import unittest +from unittest.mock import patch + +from sn_basis.functions.settings_logic import SettingsLogic + + +class TestSettingsLogic(unittest.TestCase): + + # ----------------------------------------------------- + # Test: load() liest alle Variablen über get_variable() + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.get_variable") + def test_load(self, mock_get): + # Mock-Rückgabe für jede Variable + mock_get.side_effect = lambda key, scope="project": f"wert_{key}" + + logic = SettingsLogic() + daten = logic.load() + + # Alle Variablen müssen enthalten sein + for key in SettingsLogic.VARIABLEN: + self.assertIn(key, daten) + self.assertEqual(daten[key], f"wert_{key}") + + # get_variable muss für jede Variable genau einmal aufgerufen werden + self.assertEqual(mock_get.call_count, len(SettingsLogic.VARIABLEN)) + + # ----------------------------------------------------- + # Test: save() ruft set_variable() nur für bekannte Keys auf + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.set_variable") + def test_save(self, mock_set): + logic = SettingsLogic() + + # Eingabedaten enthalten gültige und ungültige Keys + daten = { + "amt": "A1", + "behoerde": "B1", + "unbekannt": "IGNORIEREN", + "gemeinden": "G1", + } + + logic.save(daten) + + # set_variable muss nur für gültige Keys aufgerufen werden + expected_calls = 3 # amt, behoerde, gemeinden + self.assertEqual(mock_set.call_count, expected_calls) + + # Prüfen, ob die richtigen Keys gespeichert wurden + saved_keys = [call.args[0] for call in mock_set.call_args_list] + self.assertIn("amt", saved_keys) + self.assertIn("behoerde", saved_keys) + self.assertIn("gemeinden", saved_keys) + self.assertNotIn("unbekannt", saved_keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_stilpruefer.py b/test/test_stilpruefer.py index 6ee2a86..28ec3e8 100644 --- a/test/test_stilpruefer.py +++ b/test/test_stilpruefer.py @@ -1,50 +1,79 @@ -#test_stilpruefer.py +# sn_basis/test/test_stilpruefer.py + import unittest import tempfile import os -import sys +from unittest.mock import patch + +from sn_basis.modules.stilpruefer import Stilpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) -from modules.stilpruefer import Stilpruefer -from modules.pruef_ergebnis import PruefErgebnis class TestStilpruefer(unittest.TestCase): + def setUp(self): self.pruefer = Stilpruefer() + # ----------------------------------------------------- + # 1. Keine Datei angegeben + # ----------------------------------------------------- def test_keine_datei_angegeben(self): result = self.pruefer.pruefe("") - self.assertTrue(result.erfolgreich) - self.assertIn("Kein Stil angegeben.", result.warnungen) - self.assertIsNone(result.daten["stil"]) - def test_datei_existiert_mit_qml(self): - with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp_file: - tmp_path = tmp_file.name + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertIn("Kein Stil angegeben", result.meldung) + + # ----------------------------------------------------- + # 2. Datei existiert und ist .qml + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_existiert_mit_qml(self, mock_isfile, mock_exists): + with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp: + tmp_path = tmp.name + try: result = self.pruefer.pruefe(tmp_path) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten["stil"], tmp_path) - self.assertEqual(result.fehler, []) + + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertEqual(result.pfad, tmp_path) + finally: os.remove(tmp_path) - def test_datei_existiert_falsche_endung(self): - with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp_file: - tmp_path = tmp_file.name + # ----------------------------------------------------- + # 3. Datei existiert, aber falsche Endung + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_existiert_falsche_endung(self, mock_isfile, mock_exists): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: + tmp_path = tmp.name + try: result = self.pruefer.pruefe(tmp_path) - self.assertFalse(result.erfolgreich) - self.assertIn("Ungültige Dateiendung", result.fehler[0]) + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "falsche_endung") + self.assertIn(".qml", result.meldung) + finally: os.remove(tmp_path) - def test_datei_existiert_nicht(self): - fake_path = os.path.join(tempfile.gettempdir(), "nichtvorhanden.qml") + # ----------------------------------------------------- + # 4. Datei existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + def test_datei_existiert_nicht(self, mock_exists): + fake_path = "/tmp/nichtvorhanden.qml" + result = self.pruefer.pruefe(fake_path) - self.assertFalse(result.erfolgreich) - self.assertIn("Stildatei nicht gefunden", result.fehler[0]) + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "datei_nicht_gefunden") + self.assertIn("nicht gefunden", result.meldung) if __name__ == "__main__": diff --git a/test/test_wrapper.py b/test/test_wrapper.py new file mode 100644 index 0000000..f57b5bb --- /dev/null +++ b/test/test_wrapper.py @@ -0,0 +1,164 @@ +# sn_basis/test/test_wrapper.py + +import unittest +import os +import tempfile + +# Wrapper importieren +import sn_basis.functions.syswrapper as syswrapper +import sn_basis.functions.qgisqt_wrapper as qgisqt + + +# --------------------------------------------------------- +# Mock-Layer für qgisqt_wrapper +# --------------------------------------------------------- +class MockLayer: + def __init__( + self, + exists=True, + visible=True, + layer_type="vector", + geometry_type="Polygon", + feature_count=10, + crs="EPSG:25833", + fields=None, + source="/tmp/test.shp", + editable=True, + ): + self.exists = exists + self.visible = visible + self.layer_type = layer_type + self.geometry_type = geometry_type + self.feature_count = feature_count + self.crs = crs + self.fields = fields or [] + self.source = source + self.editable = editable + + +# --------------------------------------------------------- +# Monkeypatching für qgisqt_wrapper +# --------------------------------------------------------- +def mock_layer_exists(layer): + return layer is not None and layer.exists + + +def mock_is_layer_visible(layer): + return layer.visible + + +def mock_get_layer_type(layer): + return layer.layer_type + + +def mock_get_layer_geometry_type(layer): + return layer.geometry_type + + +def mock_get_layer_feature_count(layer): + return layer.feature_count + + +def mock_get_layer_crs(layer): + return layer.crs + + +def mock_get_layer_fields(layer): + return layer.fields + + +def mock_get_layer_source(layer): + return layer.source + + +def mock_is_layer_editable(layer): + return layer.editable + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- +class TestWrapper(unittest.TestCase): + + def setUp(self): + # qgisqt_wrapper monkeypatchen + qgisqt.layer_exists = mock_layer_exists + qgisqt.is_layer_visible = mock_is_layer_visible + qgisqt.get_layer_type = mock_get_layer_type + qgisqt.get_layer_geometry_type = mock_get_layer_geometry_type + qgisqt.get_layer_feature_count = mock_get_layer_feature_count + qgisqt.get_layer_crs = mock_get_layer_crs + qgisqt.get_layer_fields = mock_get_layer_fields + qgisqt.get_layer_source = mock_get_layer_source + qgisqt.is_layer_editable = mock_is_layer_editable + + # ----------------------------------------------------- + # syswrapper Tests + # ----------------------------------------------------- + + def test_syswrapper_file_exists(self): + with tempfile.NamedTemporaryFile(delete=True) as tmp: + self.assertTrue(syswrapper.file_exists(tmp.name)) + self.assertFalse(syswrapper.file_exists("/path/does/not/exist")) + + def test_syswrapper_is_file(self): + with tempfile.NamedTemporaryFile(delete=True) as tmp: + self.assertTrue(syswrapper.is_file(tmp.name)) + self.assertFalse(syswrapper.is_file("/path/does/not/exist")) + + def test_syswrapper_join_path(self): + result = syswrapper.join_path("/tmp", "test.txt") + self.assertEqual(result, "/tmp/test.txt") + + # ----------------------------------------------------- + # qgisqt_wrapper Tests (Mock-Modus) + # ----------------------------------------------------- + + def test_qgisqt_layer_exists(self): + layer = MockLayer(exists=True) + self.assertTrue(qgisqt.layer_exists(layer)) + + layer = MockLayer(exists=False) + self.assertFalse(qgisqt.layer_exists(layer)) + + def test_qgisqt_layer_visible(self): + layer = MockLayer(visible=True) + self.assertTrue(qgisqt.is_layer_visible(layer)) + + layer = MockLayer(visible=False) + self.assertFalse(qgisqt.is_layer_visible(layer)) + + def test_qgisqt_layer_type(self): + layer = MockLayer(layer_type="vector") + self.assertEqual(qgisqt.get_layer_type(layer), "vector") + + def test_qgisqt_geometry_type(self): + layer = MockLayer(geometry_type="Polygon") + self.assertEqual(qgisqt.get_layer_geometry_type(layer), "Polygon") + + def test_qgisqt_feature_count(self): + layer = MockLayer(feature_count=12) + self.assertEqual(qgisqt.get_layer_feature_count(layer), 12) + + def test_qgisqt_crs(self): + layer = MockLayer(crs="EPSG:4326") + self.assertEqual(qgisqt.get_layer_crs(layer), "EPSG:4326") + + def test_qgisqt_fields(self): + layer = MockLayer(fields=["id", "name"]) + self.assertEqual(qgisqt.get_layer_fields(layer), ["id", "name"]) + + def test_qgisqt_source(self): + layer = MockLayer(source="/tmp/test.shp") + self.assertEqual(qgisqt.get_layer_source(layer), "/tmp/test.shp") + + def test_qgisqt_editable(self): + layer = MockLayer(editable=True) + self.assertTrue(qgisqt.is_layer_editable(layer)) + + layer = MockLayer(editable=False) + self.assertFalse(qgisqt.is_layer_editable(layer)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py index 4184b6d..9ee75af 100644 --- a/ui/base_dockwidget.py +++ b/ui/base_dockwidget.py @@ -1,28 +1,73 @@ +# sn_basis/ui/base_dockwidget.py + from qgis.PyQt.QtWidgets import QDockWidget, QTabWidget +from sn_basis.functions.qgisqt_wrapper import warning, error + class BaseDockWidget(QDockWidget): + """ + Basis-Dockwidget für alle LNO-Module. + - Titel wird automatisch aus base_title + subtitle erzeugt + - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt + - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt + """ + base_title = "LNO Sachsen" - tabs = [] - action = None # Referenz auf die Toolbar-Action + tabs = [] # Liste von Tab-Klassen + action = None # Referenz auf die Toolbar-Action def __init__(self, parent=None, subtitle=""): super().__init__(parent) - # Titel zusammensetzen - title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}" - self.setWindowTitle(title) + # ----------------------------------------------------- + # Titel setzen + # ----------------------------------------------------- + try: + title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}" + self.setWindowTitle(title) + except Exception as e: + warning("Titel konnte nicht gesetzt werden", str(e)) - # Dock fixieren (nur schließen erlaubt) - self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) + # ----------------------------------------------------- + # Dock-Features + # ----------------------------------------------------- + try: + self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) + except Exception as e: + warning("Dock-Features konnten nicht gesetzt werden", str(e)) - # Tabs hinzufügen - tab_widget = QTabWidget() - for tab_class in self.tabs: - tab_widget.addTab(tab_class(), getattr(tab_class, "tab_title", tab_class.__name__)) - self.setWidget(tab_widget) + # ----------------------------------------------------- + # Tabs erzeugen + # ----------------------------------------------------- + try: + tab_widget = QTabWidget() + + for tab_class in self.tabs: + try: + tab_instance = tab_class() + tab_title = getattr(tab_class, "tab_title", tab_class.__name__) + tab_widget.addTab(tab_instance, tab_title) + except Exception as e: + error("Tab konnte nicht geladen werden", f"{tab_class}: {e}") + + self.setWidget(tab_widget) + + except Exception as e: + error("Tab-Widget konnte nicht initialisiert werden", str(e)) + + # --------------------------------------------------------- + # Dock schließen + # --------------------------------------------------------- def closeEvent(self, event): - """Wird aufgerufen, wenn das Dock geschlossen wird.""" - if self.action: - self.action.setChecked(False) # Toolbar-Button zurücksetzen + """ + Wird aufgerufen, wenn das Dock geschlossen wird. + Setzt die zugehörige Toolbar-Action zurück. + """ + try: + if self.action: + self.action.setChecked(False) + except Exception as e: + warning("Toolbar-Status konnte nicht zurückgesetzt werden", str(e)) + super().closeEvent(event) diff --git a/ui/dockmanager.py b/ui/dockmanager.py index 50bdd34..8830e60 100644 --- a/ui/dockmanager.py +++ b/ui/dockmanager.py @@ -1,21 +1,53 @@ +# sn_basis/ui/dockmanager.py + from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtWidgets import QDockWidget from qgis.utils import iface +from sn_basis.functions.qgisqt_wrapper import warning, error + class DockManager: + """ + Verwaltet das Anzeigen und Ersetzen von DockWidgets. + Stellt sicher, dass immer nur ein LNO-Dock gleichzeitig sichtbar ist. + """ + default_area = Qt.DockWidgetArea.RightDockWidgetArea + dock_prefix = "sn_dock_" @classmethod def show(cls, dock_widget, area=None): - area = area or cls.default_area + """ + Zeigt ein DockWidget an und entfernt vorher alle anderen + LNO-Docks (erkennbar am Prefix 'sn_dock_'). + """ + if dock_widget is None: + error("Dock konnte nicht angezeigt werden", "Dock-Widget ist None.") + return - # Bestehende Plugin-Docks mit Präfix schließen - for widget in iface.mainWindow().findChildren(QDockWidget): - if widget is not dock_widget and widget.objectName().startswith("sn_dock_"): - iface.removeDockWidget(widget) - widget.deleteLater() + try: + area = area or cls.default_area - # Neues Dock anzeigen - iface.addDockWidget(area, dock_widget) - dock_widget.show() + # Prüfen, ob das Dock einen gültigen Namen hat + if not dock_widget.objectName(): + dock_widget.setObjectName(f"{cls.dock_prefix}{id(dock_widget)}") + + # Bestehende Plugin-Docks schließen + try: + for widget in iface.mainWindow().findChildren(QDockWidget): + if widget is not dock_widget and widget.objectName().startswith(cls.dock_prefix): + iface.removeDockWidget(widget) + widget.deleteLater() + except Exception as e: + warning("Vorherige Docks konnten nicht entfernt werden", str(e)) + + # Neues Dock anzeigen + try: + iface.addDockWidget(area, dock_widget) + dock_widget.show() + except Exception as e: + error("Dock konnte nicht angezeigt werden", str(e)) + + except Exception as e: + error("DockManager-Fehler", str(e)) diff --git a/ui/navigation.py b/ui/navigation.py index 44a8895..36c786c 100644 --- a/ui/navigation.py +++ b/ui/navigation.py @@ -1,3 +1,4 @@ +#sn_basis/ui/navigation.py from qgis.PyQt.QtWidgets import QAction, QMenu, QToolBar, QActionGroup class Navigation: diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py index a8f5de9..ae164f3 100644 --- a/ui/tabs/settings_tab.py +++ b/ui/tabs/settings_tab.py @@ -1,12 +1,21 @@ -from qgis.PyQt.QtWidgets import ( +# sn_basis/ui/tabs/settings_tab.py + +from sn_basis.functions.qgisqt_wrapper import ( QWidget, QGridLayout, QLabel, QLineEdit, - QGroupBox, QVBoxLayout, QPushButton + QGroupBox, QVBoxLayout, QPushButton, + info, warning, error ) + from sn_basis.functions.settings_logic import SettingsLogic class SettingsTab(QWidget): - tab_title = "Projekteigenschaften" # Titel für den Tab + """ + Tab für benutzer- und projektspezifische Einstellungen. + Nutzt SettingsLogic für das Laden/Speichern und den Wrapper für Meldungen. + """ + + tab_title = "Projekteigenschaften" def __init__(self, parent=None): super().__init__(parent) @@ -14,13 +23,16 @@ class SettingsTab(QWidget): main_layout = QVBoxLayout() + # ----------------------------------------------------- # Definition der Felder + # ----------------------------------------------------- self.user_fields = { "amt": "Amt:", "behoerde": "Behörde:", "landkreis_user": "Landkreis:", "sachgebiet": "Sachgebiet:" } + self.project_fields = { "bezeichnung": "Bezeichnung:", "verfahrensnummer": "Verfahrensnummer:", @@ -28,45 +40,90 @@ class SettingsTab(QWidget): "landkreise_proj": "Landkreis(e):" } - # 🟦 Benutzerspezifische Festlegungen + # ----------------------------------------------------- + # Benutzer-Felder + # ----------------------------------------------------- user_group = QGroupBox("Benutzerspezifische Festlegungen") user_layout = QGridLayout() self.user_inputs = {} + for row, (key, label) in enumerate(self.user_fields.items()): - self.user_inputs[key] = QLineEdit() + line_edit = QLineEdit() + self.user_inputs[key] = line_edit user_layout.addWidget(QLabel(label), row, 0) - user_layout.addWidget(self.user_inputs[key], row, 1) + user_layout.addWidget(line_edit, row, 1) + user_group.setLayout(user_layout) - # 🟨 Projektspezifische Festlegungen + # ----------------------------------------------------- + # Projekt-Felder + # ----------------------------------------------------- project_group = QGroupBox("Projektspezifische Festlegungen") project_layout = QGridLayout() self.project_inputs = {} + for row, (key, label) in enumerate(self.project_fields.items()): - self.project_inputs[key] = QLineEdit() + line_edit = QLineEdit() + self.project_inputs[key] = line_edit project_layout.addWidget(QLabel(label), row, 0) - project_layout.addWidget(self.project_inputs[key], row, 1) + project_layout.addWidget(line_edit, row, 1) + project_group.setLayout(project_layout) - # 🟩 Speichern-Button + # ----------------------------------------------------- + # Speichern-Button + # ----------------------------------------------------- save_button = QPushButton("Speichern") save_button.clicked.connect(self.save_data) + # ----------------------------------------------------- # Layout zusammenfügen + # ----------------------------------------------------- main_layout.addWidget(user_group) main_layout.addWidget(project_group) main_layout.addStretch() main_layout.addWidget(save_button) self.setLayout(main_layout) + + # Daten laden self.load_data() + # --------------------------------------------------------- + # Speichern + # --------------------------------------------------------- + def save_data(self): - # Alle Felder zusammenführen - fields = {key: widget.text() for key, widget in {**self.user_inputs, **self.project_inputs}.items()} - self.logic.save(fields) + """ + Speichert alle Eingaben über SettingsLogic. + Fehler werden über den Wrapper gemeldet. + """ + try: + fields = { + key: widget.text() + for key, widget in {**self.user_inputs, **self.project_inputs}.items() + } + + self.logic.save(fields) + info("Gespeichert", "Die Einstellungen wurden erfolgreich gespeichert.") + + except Exception as e: + error("Fehler beim Speichern", str(e)) + + # --------------------------------------------------------- + # Laden + # --------------------------------------------------------- def load_data(self): - data = self.logic.load() - for key, widget in {**self.user_inputs, **self.project_inputs}.items(): - widget.setText(data.get(key, "")) + """ + Lädt gespeicherte Einstellungen und füllt die Felder. + Fehler werden über den Wrapper gemeldet. + """ + try: + data = self.logic.load() + + for key, widget in {**self.user_inputs, **self.project_inputs}.items(): + widget.setText(data.get(key, "")) + + except Exception as e: + warning("Einstellungen konnten nicht geladen werden", str(e))