From f64d56d4bcddeb86555cc330da612ed05f507bb2 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 17 Dec 2025 17:45:18 +0100 Subject: [PATCH] =?UTF-8?q?Tests=20=C3=BCberarbeitet,=20Mocks=20und=20cove?= =?UTF-8?q?rage=20eingef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coveragerc | 12 +++ assets/moduluebersicht.md | 1 + modules/Dateipruefer.py | 3 + modules/Datenbankpruefer.py | 1 + modules/Pruefmanager.py | 39 ++++----- modules/linkpruefer.py | 73 +++++++---------- modules/pruef_ergebnis | 11 --- modules/pruef_ergebnis.py | 1 + modules/qt_compat.py | 111 +++++++++++++++++++++++++ modules/stilpruefer.py | 3 +- test/run_tests.py | 100 ++++++++++++++++++++++- test/test_dateipruefer.py | 8 +- test/test_linkpruefer.py | 159 +++++++++++++----------------------- test/test_pruefmanager.py | 79 ++++++++++++++---- test/test_qgis.bat | 52 ++++++++++++ test/test_qt_compat.py | 100 +++++++++++++++++++++++ test/test_stilpruefer.py | 10 ++- 17 files changed, 562 insertions(+), 201 deletions(-) create mode 100644 .coveragerc create mode 100644 modules/Datenbankpruefer.py delete mode 100644 modules/pruef_ergebnis create mode 100644 modules/qt_compat.py create mode 100644 test/test_qgis.bat create mode 100644 test/test_qt_compat.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4087545 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +source = modules +omit = + */test/* + */__init__.py + +[report] +show_missing = True +skip_covered = False + +[html] +directory = coverage_html diff --git a/assets/moduluebersicht.md b/assets/moduluebersicht.md index af9f90c..4375bab 100644 --- a/assets/moduluebersicht.md +++ b/assets/moduluebersicht.md @@ -6,3 +6,4 @@ graph TD M1 --> M2 M1 --> M3 +``` \ No newline at end of file diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 166eccc..8e29eb1 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,3 +1,6 @@ +#Modul zur Prüfung und zum Exception Handling für Dateieingaben +#Dateipruefer.py + import os from enum import Enum, auto diff --git a/modules/Datenbankpruefer.py b/modules/Datenbankpruefer.py new file mode 100644 index 0000000..5843763 --- /dev/null +++ b/modules/Datenbankpruefer.py @@ -0,0 +1 @@ +#Datenbankpruefer.py \ No newline at end of file diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index e3b97ce..a766aa3 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,5 +1,6 @@ -from PyQt5.QtWidgets import QMessageBox, QFileDialog -from Dateipruefer import DateiEntscheidung +#Pruefmanager.py +from modules.qt_compat import QMessageBox, QFileDialog, YES, NO, CANCEL, ICON_QUESTION, exec_dialog +from modules.Dateipruefer import DateiEntscheidung class PruefManager: @@ -8,40 +9,40 @@ class PruefManager: self.plugin_pfad = plugin_pfad def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: - """Fragt den Nutzer, ob die vorhandene Datei ersetzt, angehängt oder abgebrochen werden soll.""" msg = QMessageBox() - msg.setIcon(QMessageBox.Question) + msg.setIcon(ICON_QUESTION) msg.setWindowTitle("Datei existiert") msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") - msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) - msg.setDefaultButton(QMessageBox.Yes) - msg.button(QMessageBox.Yes).setText("Ersetzen") - msg.button(QMessageBox.No).setText("Anhängen") - msg.button(QMessageBox.Cancel).setText("Abbrechen") - result = msg.exec_() + msg.setStandardButtons(YES | NO | CANCEL) + msg.setDefaultButton(YES) - if result == QMessageBox.Yes: + msg.button(YES).setText("Ersetzen") + msg.button(NO).setText("Anhängen") + msg.button(CANCEL).setText("Abbrechen") + + result = exec_dialog(msg) + + if result == YES: return DateiEntscheidung.ERSETZEN - elif result == QMessageBox.No: + elif result == NO: return DateiEntscheidung.ANHAENGEN else: return DateiEntscheidung.ABBRECHEN def frage_temporär_verwenden(self) -> bool: - """Fragt den Nutzer, ob mit temporären Layern gearbeitet werden soll.""" msg = QMessageBox() - msg.setIcon(QMessageBox.Question) + msg.setIcon(ICON_QUESTION) msg.setWindowTitle("Temporäre Layer") msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") - msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - msg.setDefaultButton(QMessageBox.Yes) - result = msg.exec_() - return result == QMessageBox.Yes + msg.setStandardButtons(YES | NO) + msg.setDefaultButton(YES) + + result = exec_dialog(msg) + return result == YES def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: - """Öffnet einen QFileDialog zur Dateiauswahl.""" pfad, _ = QFileDialog.getSaveFileName( parent=None, caption=titel, diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index b840a25..9283c85 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -1,17 +1,19 @@ -# Importiert den Event-Loop und URL-Objekte aus der PyQt-Bibliothek von QGIS -from qgis.PyQt.QtCore import QEventLoop, QUrl -# Importiert den NetworkAccessManager aus dem QGIS Core-Modul -from qgis.core import QgsNetworkAccessManager -# Importiert das QNetworkRequest-Objekt für HTTP-Anfragen -from qgis.PyQt.QtNetwork import QNetworkRequest -# Importiert die Klasse für das Ergebnisobjekt der Prüfung -from pruef_ergebnis import PruefErgebnis +# Linkpruefer.py – Qt5/Qt6-kompatibel über qt_compat + +from modules.qt_compat import ( + QEventLoop, + QUrl, + QNetworkRequest, + QNetworkReply +) + +from qgis.core import QgsNetworkAccessManager +from modules.pruef_ergebnis import PruefErgebnis + -# Definiert die Klasse zum Prüfen von Links class Linkpruefer: """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" - # Statische Zuordnung möglicher Anbietertypen als Konstanten ANBIETER_TYPEN: dict[str, str] = { "REST": "REST", "WFS": "WFS", @@ -19,76 +21,57 @@ class Linkpruefer: "OGR": "OGR" } - # Konstruktor zum Initialisieren der Instanz def __init__(self, link: str, anbieter: str): - # Speichert den übergebenen Link als Instanzvariable self.link = link - - # Speichert den Anbietertyp, bereinigt und in Großbuchstaben (auch wenn leer oder None) self.anbieter = anbieter.upper().strip() if anbieter else "" - # Erstellt einen neuen NetworkAccessManager für Netzwerkverbindungen self.network_manager = QgsNetworkAccessManager() - # Methode zur Klassifizierung des Anbieters und der Quelle def klassifiziere_anbieter(self): - # Bestimmt den Typ auf Basis der vorgegebenen Konstante oder nimmt den Rohwert typ = self.ANBIETER_TYPEN.get(self.anbieter, self.anbieter) - # Unterscheidet zwischen "remote" (http/https) oder "local" (Dateipfad) quelle = "remote" if self.link.startswith(("http://", "https://")) else "local" - # Gibt Typ und Quelle als Dictionary zurück - return { - "typ": typ, - "quelle": quelle - } + return {"typ": typ, "quelle": quelle} - - # Prüft die Erreichbarkeit und Plausibilität des Links def pruefe_link(self): - # Initialisiert Listen für Fehler und Warnungen fehler = [] warnungen = [] - # Prüft, ob ein Link übergeben wurde if not self.link: fehler.append("Link fehlt.") return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) - - # Prüft, ob ein Anbieter angegeben ist + if not self.anbieter or not self.anbieter.strip(): fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") - # Prüfung für Remote-Links (http/https) + # Remote-Links prüfen if self.link.startswith(("http://", "https://")): - # Erstellt eine HTTP-Anfrage mit dem Link request = QNetworkRequest(QUrl(self.link)) - # Startet eine HEAD-Anfrage über den NetworkManager reply = self.network_manager.head(request) - # Wartet synchron auf die Netzwerkanwort (Event Loop) loop = QEventLoop() reply.finished.connect(loop.quit) - loop.exec_() + loop.exec() # Qt5/Qt6-kompatibel über qt_compat - # Prüft auf Netzwerkfehler - if reply.error(): + # Fehlerprüfung Qt5/Qt6-kompatibel + if reply.error() != QNetworkReply.NetworkError.NoError: fehler.append(f"Verbindungsfehler: {reply.errorString()}") else: - # Holt den HTTP-Statuscode aus der Antwort - status = reply.attribute(reply.HttpStatusCodeAttribute) - # Prüft, ob der Status außerhalb des Erfolgsbereichs liegt + status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status is None or status < 200 or status >= 400: fehler.append(f"Link nicht erreichbar: HTTP {status}") - # Räumt die Antwort auf (Vermeidung von Speicherlecks) + reply.deleteLater() + else: - # Plausibilitäts-Check für lokale Links (Dateien), prüft auf Dateiendung + # Lokale Pfade: Plausibilitätscheck if "." not in self.link.split("/")[-1]: warnungen.append("Der lokale Link sieht ungewöhnlich aus.") - # Gibt das Ergebnisobjekt mit allen gesammelten Informationen zurück - return PruefErgebnis(len(fehler) == 0, daten=self.klassifiziere_anbieter(), fehler=fehler, warnungen=warnungen) + return PruefErgebnis( + len(fehler) == 0, + daten=self.klassifiziere_anbieter(), + fehler=fehler, + warnungen=warnungen + ) - # Führt die Linkprüfung als externe Methode aus def ausfuehren(self): - # Gibt das Ergebnis der Prüf-Methode zurück return self.pruefe_link() diff --git a/modules/pruef_ergebnis b/modules/pruef_ergebnis deleted file mode 100644 index 4f9b719..0000000 --- a/modules/pruef_ergebnis +++ /dev/null @@ -1,11 +0,0 @@ -# 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 [] - - def __repr__(self): - return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " - f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index 4f9b719..54ecda4 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,3 +1,4 @@ +#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): diff --git a/modules/qt_compat.py b/modules/qt_compat.py new file mode 100644 index 0000000..dca7495 --- /dev/null +++ b/modules/qt_compat.py @@ -0,0 +1,111 @@ +""" +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 1ac65b1..dc43f4d 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -1,5 +1,6 @@ +#stilpruefer.py import os -from pruef_ergebnis import PruefErgebnis +from modules.pruef_ergebnis import PruefErgebnis class Stilpruefer: diff --git a/test/run_tests.py b/test/run_tests.py index 6f94a3a..a0ec181 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -1,12 +1,106 @@ +#run_tests.py import sys import os import unittest +import datetime +import inspect + +# Farben +RED = "\033[91m" +YELLOW = "\033[93m" +GREEN = "\033[92m" +CYAN = "\033[96m" +MAGENTA = "\033[95m" +RESET = "\033[0m" + +# Globaler Testzähler +GLOBAL_TEST_COUNTER = 0 + + +# --------------------------------------------------------- +# Eigene TestResult-Klasse (färbt Fehler/Skipped/OK) +# --------------------------------------------------------- +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"Starte Testklasse: {filename} → {cls.__name__}\n" + f"{'='*70}{RESET}\n" + ) + + def addError(self, test, err): + super().addError(test, err) + self.stream.write(f"{RED}ERROR{RESET}\n") + + def addFailure(self, test, err): + super().addFailure(test, err) + self.stream.write(f"{RED}FAILURE{RESET}\n") + + def addSkip(self, test, reason): + 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 +# --------------------------------------------------------- +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 + return result + + +# --------------------------------------------------------- +# 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(): loader = unittest.TestLoader() suite = unittest.TestSuite() @@ -15,15 +109,17 @@ def main(): "test_dateipruefer", "test_stilpruefer", "test_linkpruefer", - # "test_pruefmanager" enthält QGIS-spezifische Funktionen + "test_qt_compat", + "test_pruefmanager", ] for mod_name in test_modules: mod = __import__(mod_name) suite.addTests(loader.loadTestsFromModule(mod)) - runner = unittest.TextTestRunner(verbosity=2) + runner = ColoredTestRunner(verbosity=2) runner.run(suite) + if __name__ == "__main__": main() diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py index 6f8ff7d..f6f537b 100644 --- a/test/test_dateipruefer.py +++ b/test/test_dateipruefer.py @@ -1,10 +1,12 @@ +#test_dateipruefer.py import unittest import os import tempfile import sys - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from Dateipruefer import ( +# 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, diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py index 35baeb3..d9d4206 100644 --- a/test/test_linkpruefer.py +++ b/test/test_linkpruefer.py @@ -1,125 +1,78 @@ -# test/test_linkpruefer.py - +#test_linkpruefer.py import unittest -import sys -from unittest.mock import patch -from qgis.PyQt.QtCore import QCoreApplication, QTimer -from qgis.PyQt.QtNetwork import QNetworkRequest +from unittest.mock import MagicMock, patch -from linkpruefer import Linkpruefer - -# Stelle sicher, dass eine Qt-App existiert -app = QCoreApplication.instance() -if app is None: - app = QCoreApplication(sys.argv) - - -class DummyReply: - """Fake-Reply, um Netzwerkabfragen zu simulieren""" - HttpStatusCodeAttribute = QNetworkRequest.HttpStatusCodeAttribute - - def __init__(self, error, status_code): - self._error = error - self._status_code = status_code - self.finished = self # Fake-Signal - - def connect(self, slot): - # sorgt dafür, dass loop.quit() nach Start von loop.exec_() ausgeführt wird - QTimer.singleShot(0, slot) - - def error(self): - return self._error - - def errorString(self): - return "Simulierter Fehler" if self._error != 0 else "" - - def attribute(self, attr): - if attr == self.HttpStatusCodeAttribute: - return self._status_code - return None - - def deleteLater(self): - # kein echtes QObject → wir tun einfach nichts - pass +# 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 class TestLinkpruefer(unittest.TestCase): - """Tests für alle Funktionen des Linkpruefer""" - # ---------------------------- - # Remote-Tests mit DummyReply - # ---------------------------- - @patch('linkpruefer.QgsNetworkAccessManager.head') - def test_remote_link_success(self, mock_head): - mock_head.return_value = DummyReply(0, 200) + @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 - checker = Linkpruefer("https://example.com/service", "REST") - result = checker.ausfuehren() + mock_manager.return_value.head.return_value = reply_instance + + lp = Linkpruefer("http://example.com", "REST") + result = lp.pruefe_link() self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten['typ'], 'REST') - self.assertEqual(result.daten['quelle'], 'remote') - self.assertEqual(result.fehler, []) - self.assertEqual(result.warnungen, []) + self.assertEqual(result.daten["quelle"], "remote") - @patch('linkpruefer.QgsNetworkAccessManager.head') - def test_remote_link_failure(self, mock_head): - mock_head.return_value = DummyReply(1, 404) + @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" - checker = Linkpruefer("https://example.com/kaputt", "WMS") - result = checker.ausfuehren() + # 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 + + lp = Linkpruefer("http://example.com", "REST") + result = lp.pruefe_link() self.assertFalse(result.erfolgreich) - self.assertIn("Verbindungsfehler: Simulierter Fehler", result.fehler) + self.assertIn("Verbindungsfehler", result.fehler[0]) - # ---------------------------- - # Klassifizierungstests - # ---------------------------- - def test_klassifiziere_anbieter_remote(self): - checker = Linkpruefer("https://beispiel.de", "wms") - daten = checker.klassifiziere_anbieter() - self.assertEqual(daten["typ"], "WMS") - self.assertEqual(daten["quelle"], "remote") - def test_klassifiziere_anbieter_local(self): - checker = Linkpruefer("C:/daten/test.shp", "ogr") - daten = checker.klassifiziere_anbieter() - self.assertEqual(daten["typ"], "OGR") - self.assertEqual(daten["quelle"], "local") + def test_local_link_warning(self): + lp = Linkpruefer("/path/to/file_without_extension", "OGR") + result = lp.pruefe_link() - # ---------------------------- - # Lokale Links - # ---------------------------- - def test_pruefe_link_local_ok(self): - checker = Linkpruefer("C:/daten/test.shp", "OGR") - result = checker.pruefe_link() - self.assertTrue(result.erfolgreich) - self.assertEqual(result.warnungen, []) - - def test_pruefe_link_local_warnung(self): - checker = Linkpruefer("C:/daten/ordner/", "OGR") - result = checker.pruefe_link() self.assertTrue(result.erfolgreich) self.assertIn("ungewöhnlich", result.warnungen[0]) - # ---------------------------- - # Sonderfall: leerer Link - # ---------------------------- - def test_pruefe_link_empty(self): - checker = Linkpruefer("", "REST") - result = checker.pruefe_link() - self.assertFalse(result.erfolgreich) - self.assertIn("Link fehlt.", result.fehler) - - # ---------------------------- - # leerer Anbieter - # ---------------------------- - def test_pruefe_link_leerer_anbieter(self): - checker = Linkpruefer("https://example.com/service", "") - result = checker.pruefe_link() - self.assertFalse(result.erfolgreich) - self.assertIn("Anbieter muss gesetzt werden und darf nicht leer sein.", result.fehler) - if __name__ == "__main__": unittest.main() diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py index a33d4e5..dd23c31 100644 --- a/test/test_pruefmanager.py +++ b/test/test_pruefmanager.py @@ -1,36 +1,87 @@ +#test_pruefmanager.py import unittest import os -from unittest.mock import patch -from pruefmanager import PruefManager -from Dateipruefer import DateiEntscheidung import sys +from unittest.mock import patch, MagicMock + +# 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 + + +# 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." + ) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) class TestPruefManager(unittest.TestCase): def setUp(self): self.manager = PruefManager(plugin_pfad="/tmp") - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) - def test_frage_datei_ersetzen(self, mock_msgbox): + # --------------------------------------------------------- + # Tests für frage_datei_ersetzen_oder_anhaengen + # --------------------------------------------------------- + + @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) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) - def test_frage_datei_anhaengen(self, mock_msgbox): + @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) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Cancel) - def test_frage_datei_abbrechen(self, mock_msgbox): + @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) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) - def test_frage_temporär_verwenden_ja(self, mock_msgbox): + # --------------------------------------------------------- + # Fehlerfall: exec_dialog liefert etwas Unerwartetes + # --------------------------------------------------------- + + @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) + + # --------------------------------------------------------- + # Tests für frage_temporär_verwenden + # --------------------------------------------------------- + + @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()) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) - def test_frage_temporär_verwenden_nein(self, mock_msgbox): + @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()) + + # --------------------------------------------------------- + # Fehlerfall: exec_dialog liefert etwas Unerwartetes + # --------------------------------------------------------- + + @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()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qgis.bat b/test/test_qgis.bat new file mode 100644 index 0000000..fc9f9bc --- /dev/null +++ b/test/test_qgis.bat @@ -0,0 +1,52 @@ +@echo off +setlocal +echo BATCH WIRD AUSGEFÜHRT +pause + +echo ================================================ +echo Starte Tests in QGIS-Python-Umgebung +echo ================================================ + +REM Pfad zur QGIS-Installation +set QGIS_BIN=D:\OSGeo\bin + +REM Prüfen, ob python-qgis.bat existiert +if not exist "%QGIS_BIN%\python-qgis.bat" ( + echo. + echo [FEHLER] python-qgis.bat wurde nicht gefunden! + echo Erwarteter Pfad: + echo %QGIS_BIN%\python-qgis.bat + echo. + echo Bitte korrigiere den Pfad in test_qgis.bat. + echo. + pause + exit /b 1 +) + +echo. +echo [INFO] QGIS-Python gefunden. Starte Tests... +echo. + +"%QGIS_BIN%\python-qgis.bat" -m coverage run run_tests.py +if errorlevel 1 ( + echo. + echo [FEHLER] Testlauf fehlgeschlagen. + echo. + pause + exit /b 1 +) + +echo. +echo ================================================ +echo Coverage HTML-Bericht wird erzeugt... +echo ================================================ + +"%QGIS_BIN%\python-qgis.bat" -m coverage html + +echo. +echo Fertig! +echo Öffne jetzt: coverage_html\index.html +echo ================================================ + +pause +endlocal diff --git a/test/test_qt_compat.py b/test/test_qt_compat.py new file mode 100644 index 0000000..92bfd31 --- /dev/null +++ b/test/test_qt_compat.py @@ -0,0 +1,100 @@ +#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_stilpruefer.py b/test/test_stilpruefer.py index ea37db7..6ee2a86 100644 --- a/test/test_stilpruefer.py +++ b/test/test_stilpruefer.py @@ -1,10 +1,14 @@ +#test_stilpruefer.py import unittest import tempfile import os -from stilpruefer import Stilpruefer -from pruef_ergebnis import PruefErgebnis - +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.stilpruefer import Stilpruefer +from modules.pruef_ergebnis import PruefErgebnis class TestStilpruefer(unittest.TestCase): def setUp(self): self.pruefer = Stilpruefer()