Tests überarbeitet, Mocks und coverage eingefügt

This commit is contained in:
2025-12-17 17:45:18 +01:00
parent 2d67ce8adc
commit f64d56d4bc
17 changed files with 562 additions and 201 deletions

12
.coveragerc Normal file
View File

@@ -0,0 +1,12 @@
[run]
source = modules
omit =
*/test/*
*/__init__.py
[report]
show_missing = True
skip_covered = False
[html]
directory = coverage_html

View File

@@ -6,3 +6,4 @@ graph TD
M1 --> M2 M1 --> M2
M1 --> M3 M1 --> M3
```

View File

@@ -1,3 +1,6 @@
#Modul zur Prüfung und zum Exception Handling für Dateieingaben
#Dateipruefer.py
import os import os
from enum import Enum, auto from enum import Enum, auto

View File

@@ -0,0 +1 @@
#Datenbankpruefer.py

View File

@@ -1,5 +1,6 @@
from PyQt5.QtWidgets import QMessageBox, QFileDialog #Pruefmanager.py
from Dateipruefer import DateiEntscheidung from modules.qt_compat import QMessageBox, QFileDialog, YES, NO, CANCEL, ICON_QUESTION, exec_dialog
from modules.Dateipruefer import DateiEntscheidung
class PruefManager: class PruefManager:
@@ -8,40 +9,40 @@ class PruefManager:
self.plugin_pfad = plugin_pfad self.plugin_pfad = plugin_pfad
def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: 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 = QMessageBox()
msg.setIcon(QMessageBox.Question) msg.setIcon(ICON_QUESTION)
msg.setWindowTitle("Datei existiert") msg.setWindowTitle("Datei existiert")
msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") 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 return DateiEntscheidung.ERSETZEN
elif result == QMessageBox.No: elif result == NO:
return DateiEntscheidung.ANHAENGEN return DateiEntscheidung.ANHAENGEN
else: else:
return DateiEntscheidung.ABBRECHEN return DateiEntscheidung.ABBRECHEN
def frage_temporär_verwenden(self) -> bool: def frage_temporär_verwenden(self) -> bool:
"""Fragt den Nutzer, ob mit temporären Layern gearbeitet werden soll."""
msg = QMessageBox() msg = QMessageBox()
msg.setIcon(QMessageBox.Question) msg.setIcon(ICON_QUESTION)
msg.setWindowTitle("Temporäre Layer") msg.setWindowTitle("Temporäre Layer")
msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?")
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg.setDefaultButton(QMessageBox.Yes)
result = msg.exec_() msg.setStandardButtons(YES | NO)
return result == QMessageBox.Yes msg.setDefaultButton(YES)
result = exec_dialog(msg)
return result == YES
def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str:
"""Öffnet einen QFileDialog zur Dateiauswahl."""
pfad, _ = QFileDialog.getSaveFileName( pfad, _ = QFileDialog.getSaveFileName(
parent=None, parent=None,
caption=titel, caption=titel,

View File

@@ -1,17 +1,19 @@
# Importiert den Event-Loop und URL-Objekte aus der PyQt-Bibliothek von QGIS # Linkpruefer.py Qt5/Qt6-kompatibel über qt_compat
from qgis.PyQt.QtCore import QEventLoop, QUrl
# Importiert den NetworkAccessManager aus dem QGIS Core-Modul from modules.qt_compat import (
from qgis.core import QgsNetworkAccessManager QEventLoop,
# Importiert das QNetworkRequest-Objekt für HTTP-Anfragen QUrl,
from qgis.PyQt.QtNetwork import QNetworkRequest QNetworkRequest,
# Importiert die Klasse für das Ergebnisobjekt der Prüfung QNetworkReply
from pruef_ergebnis import PruefErgebnis )
from qgis.core import QgsNetworkAccessManager
from modules.pruef_ergebnis import PruefErgebnis
# Definiert die Klasse zum Prüfen von Links
class Linkpruefer: class Linkpruefer:
"""Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut."""
# Statische Zuordnung möglicher Anbietertypen als Konstanten
ANBIETER_TYPEN: dict[str, str] = { ANBIETER_TYPEN: dict[str, str] = {
"REST": "REST", "REST": "REST",
"WFS": "WFS", "WFS": "WFS",
@@ -19,76 +21,57 @@ class Linkpruefer:
"OGR": "OGR" "OGR": "OGR"
} }
# Konstruktor zum Initialisieren der Instanz
def __init__(self, link: str, anbieter: str): def __init__(self, link: str, anbieter: str):
# Speichert den übergebenen Link als Instanzvariable
self.link = link self.link = link
# Speichert den Anbietertyp, bereinigt und in Großbuchstaben (auch wenn leer oder None)
self.anbieter = anbieter.upper().strip() if anbieter else "" self.anbieter = anbieter.upper().strip() if anbieter else ""
# Erstellt einen neuen NetworkAccessManager für Netzwerkverbindungen
self.network_manager = QgsNetworkAccessManager() self.network_manager = QgsNetworkAccessManager()
# Methode zur Klassifizierung des Anbieters und der Quelle
def klassifiziere_anbieter(self): 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) 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" 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): def pruefe_link(self):
# Initialisiert Listen für Fehler und Warnungen
fehler = [] fehler = []
warnungen = [] warnungen = []
# Prüft, ob ein Link übergeben wurde
if not self.link: if not self.link:
fehler.append("Link fehlt.") fehler.append("Link fehlt.")
return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) return PruefErgebnis(False, fehler=fehler, warnungen=warnungen)
# Prüft, ob ein Anbieter angegeben ist
if not self.anbieter or not self.anbieter.strip(): if not self.anbieter or not self.anbieter.strip():
fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") 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://")): if self.link.startswith(("http://", "https://")):
# Erstellt eine HTTP-Anfrage mit dem Link
request = QNetworkRequest(QUrl(self.link)) request = QNetworkRequest(QUrl(self.link))
# Startet eine HEAD-Anfrage über den NetworkManager
reply = self.network_manager.head(request) reply = self.network_manager.head(request)
# Wartet synchron auf die Netzwerkanwort (Event Loop)
loop = QEventLoop() loop = QEventLoop()
reply.finished.connect(loop.quit) reply.finished.connect(loop.quit)
loop.exec_() loop.exec() # Qt5/Qt6-kompatibel über qt_compat
# Prüft auf Netzwerkfehler # Fehlerprüfung Qt5/Qt6-kompatibel
if reply.error(): if reply.error() != QNetworkReply.NetworkError.NoError:
fehler.append(f"Verbindungsfehler: {reply.errorString()}") fehler.append(f"Verbindungsfehler: {reply.errorString()}")
else: else:
# Holt den HTTP-Statuscode aus der Antwort status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
status = reply.attribute(reply.HttpStatusCodeAttribute)
# Prüft, ob der Status außerhalb des Erfolgsbereichs liegt
if status is None or status < 200 or status >= 400: if status is None or status < 200 or status >= 400:
fehler.append(f"Link nicht erreichbar: HTTP {status}") fehler.append(f"Link nicht erreichbar: HTTP {status}")
# Räumt die Antwort auf (Vermeidung von Speicherlecks)
reply.deleteLater() reply.deleteLater()
else: else:
# Plausibilitäts-Check für lokale Links (Dateien), prüft auf Dateiendung # Lokale Pfade: Plausibilitätscheck
if "." not in self.link.split("/")[-1]: if "." not in self.link.split("/")[-1]:
warnungen.append("Der lokale Link sieht ungewöhnlich aus.") warnungen.append("Der lokale Link sieht ungewöhnlich aus.")
# Gibt das Ergebnisobjekt mit allen gesammelten Informationen zurück return PruefErgebnis(
return PruefErgebnis(len(fehler) == 0, daten=self.klassifiziere_anbieter(), fehler=fehler, warnungen=warnungen) len(fehler) == 0,
daten=self.klassifiziere_anbieter(),
fehler=fehler,
warnungen=warnungen
)
# Führt die Linkprüfung als externe Methode aus
def ausfuehren(self): def ausfuehren(self):
# Gibt das Ergebnis der Prüf-Methode zurück
return self.pruefe_link() return self.pruefe_link()

View File

@@ -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})")

View File

@@ -1,3 +1,4 @@
#pruef_ergebnis.py
# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann # Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann
class PruefErgebnis: class PruefErgebnis:
def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None):

111
modules/qt_compat.py Normal file
View File

@@ -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

View File

@@ -1,5 +1,6 @@
#stilpruefer.py
import os import os
from pruef_ergebnis import PruefErgebnis from modules.pruef_ergebnis import PruefErgebnis
class Stilpruefer: class Stilpruefer:

View File

@@ -1,12 +1,106 @@
#run_tests.py
import sys import sys
import os import os
import unittest 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 # Projekt-Root dem Suchpfad hinzufügen
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if project_root not in sys.path: if project_root not in sys.path:
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
def main(): def main():
loader = unittest.TestLoader() loader = unittest.TestLoader()
suite = unittest.TestSuite() suite = unittest.TestSuite()
@@ -15,15 +109,17 @@ def main():
"test_dateipruefer", "test_dateipruefer",
"test_stilpruefer", "test_stilpruefer",
"test_linkpruefer", "test_linkpruefer",
# "test_pruefmanager" enthält QGIS-spezifische Funktionen "test_qt_compat",
"test_pruefmanager",
] ]
for mod_name in test_modules: for mod_name in test_modules:
mod = __import__(mod_name) mod = __import__(mod_name)
suite.addTests(loader.loadTestsFromModule(mod)) suite.addTests(loader.loadTestsFromModule(mod))
runner = unittest.TextTestRunner(verbosity=2) runner = ColoredTestRunner(verbosity=2)
runner.run(suite) runner.run(suite)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,10 +1,12 @@
#test_dateipruefer.py
import unittest import unittest
import os import os
import tempfile import tempfile
import sys import sys
# Plugin-Root ermitteln (ein Verzeichnis über "test")
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
from Dateipruefer import ( sys.path.insert(0, ROOT)
from modules.Dateipruefer import (
Dateipruefer, Dateipruefer,
LeererPfadModus, LeererPfadModus,
DateiEntscheidung, DateiEntscheidung,

View File

@@ -1,125 +1,78 @@
# test/test_linkpruefer.py #test_linkpruefer.py
import unittest import unittest
import sys from unittest.mock import MagicMock, patch
from unittest.mock import patch
from qgis.PyQt.QtCore import QCoreApplication, QTimer
from qgis.PyQt.QtNetwork import QNetworkRequest
from linkpruefer import Linkpruefer # QGIS-Module mocken, damit der Import funktioniert
with patch.dict("sys.modules", {
# Stelle sicher, dass eine Qt-App existiert "qgis": MagicMock(),
app = QCoreApplication.instance() "qgis.PyQt": MagicMock(),
if app is None: "qgis.PyQt.QtCore": MagicMock(),
app = QCoreApplication(sys.argv) "qgis.PyQt.QtNetwork": MagicMock(),
"qgis.core": MagicMock(),
}):
class DummyReply: from modules.linkpruefer import Linkpruefer
"""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
class TestLinkpruefer(unittest.TestCase): class TestLinkpruefer(unittest.TestCase):
"""Tests für alle Funktionen des Linkpruefer"""
# ---------------------------- @patch("modules.linkpruefer.QNetworkReply")
# Remote-Tests mit DummyReply @patch("modules.linkpruefer.QNetworkRequest")
# ---------------------------- @patch("modules.linkpruefer.QUrl")
@patch('linkpruefer.QgsNetworkAccessManager.head') @patch("modules.linkpruefer.QEventLoop")
def test_remote_link_success(self, mock_head): @patch("modules.linkpruefer.QgsNetworkAccessManager")
mock_head.return_value = DummyReply(0, 200) 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") mock_manager.return_value.head.return_value = reply_instance
result = checker.ausfuehren()
lp = Linkpruefer("http://example.com", "REST")
result = lp.pruefe_link()
self.assertTrue(result.erfolgreich) self.assertTrue(result.erfolgreich)
self.assertEqual(result.daten['typ'], 'REST') self.assertEqual(result.daten["quelle"], "remote")
self.assertEqual(result.daten['quelle'], 'remote')
self.assertEqual(result.fehler, [])
self.assertEqual(result.warnungen, [])
@patch('linkpruefer.QgsNetworkAccessManager.head') @patch("modules.linkpruefer.QNetworkReply")
def test_remote_link_failure(self, mock_head): @patch("modules.linkpruefer.QNetworkRequest")
mock_head.return_value = DummyReply(1, 404) @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") # WICHTIG: finished-Signal simulieren
result = checker.ausfuehren() 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.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): def test_local_link_warning(self):
checker = Linkpruefer("C:/daten/test.shp", "ogr") lp = Linkpruefer("/path/to/file_without_extension", "OGR")
daten = checker.klassifiziere_anbieter() result = lp.pruefe_link()
self.assertEqual(daten["typ"], "OGR")
self.assertEqual(daten["quelle"], "local")
# ----------------------------
# 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.assertTrue(result.erfolgreich)
self.assertIn("ungewöhnlich", result.warnungen[0]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,36 +1,87 @@
#test_pruefmanager.py
import unittest import unittest
import os import os
from unittest.mock import patch
from pruefmanager import PruefManager
from Dateipruefer import DateiEntscheidung
import sys 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): class TestPruefManager(unittest.TestCase):
def setUp(self): def setUp(self):
self.manager = PruefManager(plugin_pfad="/tmp") 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") entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg")
self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN) self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN)
@patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden")
def test_frage_datei_anhaengen(self, mock_msgbox): @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") entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg")
self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN) self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN)
@patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Cancel) @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden")
def test_frage_datei_abbrechen(self, mock_msgbox): @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") entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg")
self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) 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()) self.assertTrue(self.manager.frage_temporär_verwenden())
@patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden")
def test_frage_temporär_verwenden_nein(self, mock_msgbox): @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()) 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()

52
test/test_qgis.bat Normal file
View File

@@ -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

100
test/test_qt_compat.py Normal file
View File

@@ -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()

View File

@@ -1,10 +1,14 @@
#test_stilpruefer.py
import unittest import unittest
import tempfile import tempfile
import os import os
from stilpruefer import Stilpruefer import sys
from pruef_ergebnis import PruefErgebnis
# 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): class TestStilpruefer(unittest.TestCase):
def setUp(self): def setUp(self):
self.pruefer = Stilpruefer() self.pruefer = Stilpruefer()