Files
Plugin_SN_Basis/tests/test_basemap_themen.py
T
DanielandClaude Opus 5.5 24ad564a08 Basemap hinzufügen und Thema wechseln im Basemap-Bereich (#100)
Neuer Abschnitt „Basemap-Layer“ im Basemap-Bereich des Druck-Tabs: Knopf
„Basemap hinzufügen“ und Auswahl „Thema“ (Farbe, Grau). Vorhandene Layer
werden an der Kachel-URL erkannt; Rückfrage Zusätzlich laden / Ersetzen /
Abbrechen. Vor dem Themenwechsel wird über einen Prüfwert der Darstellung
erkannt, ob sie ungespeichert verändert wurde.

Neues Modul modules/basemap_themen.py, neue Wrapper-Funktionen
ask_auswahl und get_active_layer, Stilvorlagen vermerken ihr Thema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:27:35 +02:00

374 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests für modules/basemap_themen.py und den Abschnitt „Basemap-Layer“ im Basemap-Bereich (Basis #100):
Basemap-Layer hinzufügen, vorhandenen Layer an der Quell-URL erkennen, Thema wechseln und
ungespeicherte Änderungen am Thema über den Prüfwert erkennen.
Der Vektorkachel-Layer ist ein einfacher Ersatz: ``saveNamedStyle`` schreibt seinen Darstellungstext
in die Datei, ``setDataSource`` merkt sich die Quelle. Laden über ``styleUrl`` am echten Layer ist
ein QGIS-Test.
"""
import json
import os
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from sn_basis.functions.dialog_wrapper import ask_auswahl
from sn_basis.modules import basemap_themen as bt
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
from sn_basis.ui.widgets.basemap_bereich import BasemapBereich
class _Layer:
"""Ersatz für QgsVectorTileLayer mit Quelle, Darstellung und Layer-Eigenschaften."""
def __init__(self, quelle="", name="basemap.de - Farbe", darstellung="<qgis version=\"3.44.0\"><farbe/></qgis>",
gueltig=True, laden_ok=True):
self._quelle = quelle
self._name = name
self.darstellung = darstellung
self._gueltig = gueltig
self._laden_ok = laden_ok
self.eigenschaften = {}
self.geladen = 0
self.anbieter = None
def source(self): return self._quelle
def name(self): return self._name
def isValid(self): return self._gueltig
def providerType(self): return "xyzvectortiles"
def tileMatrixSet(self): return None
def triggerRepaint(self): pass
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard)
def renderer(self): return type("R", (), {"styles": lambda s: []})()
def saveNamedStyle(self, pfad, kategorien):
with open(pfad, "w", encoding="utf-8") as datei:
datei.write(self.darstellung)
return "", True
def loadDefaultStyle(self):
self.geladen += 1
thema = bt.thema_des_layers(self) or "unbekannt"
self.darstellung = f'<qgis version="3.44.0"><{thema}/></qgis>'
return ("", True) if self._laden_ok else ("Style-JSON nicht erreichbar", False)
def setDataSource(self, quelle, name, anbieter, standardstil=False):
self._quelle, self._name, self.anbieter = quelle, name, anbieter
def _fabrik(**optionen):
return lambda quelle, name: _Layer(quelle, name, **optionen)
class TestQuelleUndThema(unittest.TestCase):
"""Layerquelle der Themen, Thema eines Layers und Erkennung über die Kachel-URL."""
def test_quelle_enthaelt_kachel_url_und_style_des_themas(self):
"""Prüft, dass die Layerquelle eines Themas die gemeinsame Kachel-URL und die Style-JSON des Themas enthält."""
layer = _Layer(bt.quelle(bt.THEMEN["grau"]))
self.assertEqual(bt.quell_url(layer), bt.KACHEL_URL)
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertIn("zmax=15", layer.source())
def test_unbekannte_style_json_ergibt_kein_thema(self):
"""Prüft, dass ein Layer mit fremder oder fehlender Style-JSON keinem Thema zugeordnet wird."""
self.assertIsNone(bt.thema_des_layers(_Layer(f"type=xyz&url={bt.KACHEL_URL}&styleUrl=https://x/eigen.json")))
self.assertIsNone(bt.thema_des_layers(_Layer(f"type=xyz&url={bt.KACHEL_URL}")))
self.assertIsNone(bt.thema_des_layers(_Layer("")))
def test_unbekannter_schluessel_faellt_auf_farbe_zurueck(self):
"""Prüft, dass ein unbekannter Themenschlüssel auf das Standardthema „Farbe“ zurückfällt."""
self.assertEqual(bt.thema("relief").schluessel, "farbe")
self.assertEqual(bt.STANDARD_THEMA, "farbe")
def test_vorhandener_layer_wird_an_der_quell_url_erkannt(self):
"""Prüft, dass ein vorhandener Basemap-Layer an der Kachel-URL erkannt wird – unabhängig von Name, Thema und URL-Kodierung."""
farbe = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Hintergrund")
grau_kodiert = _Layer(bt.quelle(bt.THEMEN["grau"]).replace("{z}/{x}/{y}", "%7Bz%7D/%7Bx%7D/%7By%7D"))
fremd = _Layer("type=xyz&url=https://example.org/{z}/{x}/{y}.pbf", name="basemap.de")
ohne_quelle = MagicMock(spec=["name"])
self.assertEqual(bt.layer_mit_quelle([farbe, grau_kodiert, fremd, ohne_quelle]), [farbe, grau_kodiert])
class TestPruefwert(unittest.TestCase):
"""Prüfwert der Darstellung und Änderungsstand für ungespeicherte Änderungen."""
def test_gleiche_darstellung_gleicher_pruefwert_auch_bei_anderer_qgis_version(self):
"""Prüft, dass dieselbe Darstellung denselben Prüfwert ergibt, auch wenn die QGIS-Version in der QML abweicht."""
a = _Layer(darstellung='<qgis version="3.44.0" styleCategories="Symbology"><x/></qgis>')
b = _Layer(darstellung='<qgis version="4.0.1" styleCategories="Symbology"><x/></qgis>')
self.assertEqual(bt.pruefwert(a), bt.pruefwert(b))
b.darstellung = '<qgis version="4.0.1"><y/></qgis>'
self.assertNotEqual(bt.pruefwert(a), bt.pruefwert(b))
def test_ohne_ausgabe_der_darstellung_kein_pruefwert(self):
"""Prüft, dass kein Prüfwert entsteht, wenn der Layer seine Darstellung nicht ausgeben kann."""
self.assertIsNone(bt.pruefwert(MagicMock(spec=["name"])))
layer = _Layer()
layer.saveNamedStyle = lambda pfad, kategorien: ("kein Schreibrecht", False)
self.assertIsNone(bt.pruefwert(layer))
def test_aenderungsstand_unbekannt_unveraendert_geaendert(self):
"""Prüft, dass der Änderungsstand ohne gemerkten Prüfwert „unbekannt“ ist, nach dem Merken „unverändert“ und nach einer Änderung „geändert“."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]))
self.assertEqual(bt.aenderungsstand(layer), bt.UNBEKANNT)
bt.merke_stand(layer)
self.assertEqual(layer.eigenschaften[bt.THEMA_EIGENSCHAFT], "farbe")
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
layer.darstellung += "<hausnummern_aus/>"
self.assertEqual(bt.aenderungsstand(layer), bt.GEAENDERT)
class TestLayerUndThemenwechsel(unittest.TestCase):
"""Basemap-Layer anlegen, in den Layerbaum einfügen und Thema wechseln."""
def test_neuer_layer_hat_thema_namen_und_gemerkten_stand(self):
"""Prüft, dass ein neuer Basemap-Layer im gewählten Thema entsteht, den Themenstil lädt und als unverändert gilt."""
layer = bt.erzeuge_layer("grau", _fabrik())
self.assertEqual(layer.name(), "basemap.de - Grau")
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertEqual(layer.geladen, 1)
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
def test_ungueltiger_layer_oder_fehlender_themenstil_meldet_fehler(self):
"""Prüft, dass ein ungültiger Layer oder ein nicht ladbarer Themenstil als ValueError gemeldet wird."""
with self.assertRaises(ValueError):
bt.erzeuge_layer("farbe", _fabrik(gueltig=False))
with self.assertRaisesRegex(ValueError, "Style-JSON nicht erreichbar"):
bt.erzeuge_layer("farbe", _fabrik(laden_ok=False))
def test_ohne_vektorkacheln_kein_layer(self):
"""Prüft, dass ohne Vektorkachel-Unterstützung (Mock-Modus) kein Layer erzeugt, sondern ein Fehler gemeldet wird."""
with patch.object(bt, "VECTOR_TILES_AVAILABLE", False):
with self.assertRaises(ValueError):
bt.erzeuge_layer("farbe")
def test_layer_kommt_ganz_unten_in_den_layerbaum(self):
"""Prüft, dass der Basemap-Layer ohne automatische Einordnung ins Projekt kommt und unten an den Layerbaum angehängt wird."""
projekt, layer = MagicMock(), _Layer()
bt.fuege_hinzu(projekt, layer)
projekt.addMapLayer.assert_called_once_with(layer, False)
projekt.layerTreeRoot.return_value.addLayer.assert_called_once_with(layer)
def test_themenwechsel_stellt_quelle_um_und_merkt_neuen_stand(self):
"""Prüft, dass der Themenwechsel die Style-JSON der Quelle umstellt, den Themenstil lädt, den automatischen Namen anpasst und den neuen Stand merkt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
bt.wechsle_thema(layer, "grau")
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertEqual(layer.name(), "basemap.de - Grau")
self.assertEqual(layer.anbieter, "xyzvectortiles")
self.assertEqual(layer.geladen, 2)
self.assertEqual(layer.eigenschaften[bt.THEMA_EIGENSCHAFT], "grau")
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
def test_themenwechsel_behaelt_eigenen_layernamen(self):
"""Prüft, dass ein vom Nutzer vergebener Layername beim Themenwechsel erhalten bleibt."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Hintergrundkarte")
bt.wechsle_thema(layer, "grau")
self.assertEqual(layer.name(), "Hintergrundkarte")
def test_themenwechsel_ohne_umstellbare_quelle_meldet_fehler(self):
"""Prüft, dass ein Layer ohne setDataSource den Themenwechsel mit einem ValueError ablehnt."""
with self.assertRaises(ValueError):
bt.wechsle_thema(MagicMock(spec=["name", "source"]), "grau")
class TestStilvorlageMitThema(unittest.TestCase):
"""Stilvorlagen vermerken das Thema des Layers."""
def setUp(self):
self.ordner = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.ordner, ignore_errors=True)
def test_stilvorlage_vermerkt_das_thema(self):
"""Prüft, dass eine gespeicherte Stilvorlage das Thema des Layers in ihrer Begleitdatei und in der Liste vermerkt."""
einstellungen = MagicMock()
einstellungen.value.return_value = ""
vorlagen = BasemapStilvorlagen(self.ordner, einstellungen)
vorlagen.speichern(_Layer(bt.quelle(bt.THEMEN["grau"])), "Grau ohne Hausnummern")
with open(os.path.join(self.ordner, "Grau_ohne_Hausnummern.json"), encoding="utf-8") as datei:
self.assertEqual(json.load(datei)["thema"], "grau")
self.assertEqual(vorlagen.liste()[0].thema, "grau")
class TestAuswahlDialog(unittest.TestCase):
"""Rückfrage mit frei benannten Knöpfen."""
def test_mock_modus_liefert_vorbelegung(self):
"""Prüft, dass die Rückfrage mit frei benannten Knöpfen im Mock-Modus den vorbelegten Schlüssel liefert."""
self.assertEqual(ask_auswahl("T", "F", [("a", "A"), ("b", "B")], default="b", abbrechen="a"), "b")
def _bereich(layer_im_projekt=(), thema="farbe", ziel=None):
bereich = BasemapBereich.__new__(BasemapBereich)
bereich._project = MagicMock()
bereich._project.mapLayers.return_value = {str(i): l for i, l in enumerate(layer_im_projekt)}
bereich._stilvorlagen = MagicMock()
bereich._layer_fabrik = _fabrik()
bereich._thema_combo = MagicMock()
bereich._thema_combo.currentData.return_value = thema
bereich._thema_combo.findData.return_value = 0
bereich._ziel_layer = MagicMock(return_value=ziel)
bereich.aktualisieren = MagicMock()
return bereich
@patch("sn_basis.ui.widgets.basemap_bereich.success")
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
class TestBasemapHinzufuegen(unittest.TestCase):
"""Knopf „Basemap hinzufügen“ mit Rückfrage bei vorhandenem Layer gleicher Quelle."""
def test_ohne_vorhandenen_layer_wird_ohne_rueckfrage_hinzugefuegt(self, _warnung, erfolg):
"""Prüft, dass ohne Basemap-Layer gleicher Quelle sofort ein Layer im gewählten Thema hinzugefügt wird."""
bereich = _bereich(thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl") as frage:
bereich._on_basemap_hinzufuegen()
frage.assert_not_called()
layer = bereich._project.addMapLayer.call_args[0][0]
self.assertEqual(bt.thema_des_layers(layer), "grau")
erfolg.assert_called_once()
def test_vorhandener_layer_wird_in_der_rueckfrage_genannt_abbrechen(self, _warnung, _erfolg):
"""Prüft, dass ein vorhandener Layer gleicher Quelle in der Rückfrage genannt wird und „Abbrechen“ nichts verändert."""
vorhanden = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Meine Basemap")
bereich = _bereich([vorhanden])
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
bereich._on_basemap_hinzufuegen()
self.assertIn("„Meine Basemap“", frage.call_args[0][1])
self.assertEqual([o[0] for o in frage.call_args[0][2]], ["zusaetzlich", "ersetzen", "abbrechen"])
bereich._project.addMapLayer.assert_not_called()
self.assertEqual(vorhanden.geladen, 0)
def test_zusaetzlich_laden_fuegt_zweiten_layer_hinzu(self, _warnung, _erfolg):
"""Prüft, dass „Zusätzlich laden“ einen weiteren Basemap-Layer neben dem vorhandenen hinzufügt."""
vorhanden = _Layer(bt.quelle(bt.THEMEN["farbe"]))
bereich = _bereich([vorhanden], thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="zusaetzlich"):
bereich._on_basemap_hinzufuegen()
neu = bereich._project.addMapLayer.call_args[0][0]
self.assertIsNot(neu, vorhanden)
self.assertEqual(bt.thema_des_layers(neu), "grau")
def test_ersetzen_laedt_vorhandenen_layer_im_gewaehlten_thema(self, _warnung, _erfolg):
"""Prüft, dass „Ersetzen“ keinen neuen Layer anlegt, sondern den vorhandenen im gewählten Thema neu lädt."""
vorhanden = bt.erzeuge_layer("farbe", _fabrik())
bereich = _bereich([vorhanden], thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="ersetzen"):
bereich._on_basemap_hinzufuegen()
bereich._project.addMapLayer.assert_not_called()
self.assertEqual(bt.thema_des_layers(vorhanden), "grau")
def test_fehler_beim_anlegen_wird_gemeldet(self, warnung, erfolg):
"""Prüft, dass ein nicht erreichbarer Dienst als Warnung gemeldet und kein Layer hinzugefügt wird."""
bereich = _bereich()
bereich._layer_fabrik = _fabrik(gueltig=False)
bereich._on_basemap_hinzufuegen()
warnung.assert_called_once()
erfolg.assert_not_called()
bereich._project.addMapLayer.assert_not_called()
@patch("sn_basis.ui.widgets.basemap_bereich.success")
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
class TestThemaWechseln(unittest.TestCase):
"""Themenauswahl mit Rückfrage bei geänderter oder unbekannter Darstellung."""
def test_unveraenderter_layer_wechselt_ohne_rueckfrage(self, _warnung, erfolg):
"""Prüft, dass ein unveränderter Basemap-Layer ohne Rückfrage auf das gewählte Thema wechselt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
bereich = _bereich(thema="grau", ziel=layer)
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl") as frage:
bereich._on_thema_gewaehlt(1)
frage.assert_not_called()
self.assertEqual(bt.thema_des_layers(layer), "grau")
erfolg.assert_called_once()
def test_gleiches_thema_aendert_nichts(self, _warnung, _erfolg):
"""Prüft, dass die Wahl des Themas, das der Layer schon hat, nichts neu lädt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
_bereich(thema="farbe", ziel=layer)._on_thema_gewaehlt(0)
self.assertEqual(layer.geladen, 1)
def test_geaenderte_darstellung_abbrechen_behaelt_thema(self, _warnung, _erfolg):
"""Prüft, dass bei geänderter Darstellung nachgefragt wird und „Abbrechen“ Thema und Darstellung unverändert lässt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
bereich._on_thema_gewaehlt(1)
self.assertIn("verändert", frage.call_args[0][1])
self.assertEqual(bt.thema_des_layers(layer), "farbe")
self.assertIn("<eigene_aenderung/>", layer.darstellung)
bereich._thema_combo.setCurrentIndex.assert_called() # Dropdown zurück auf „Farbe“
def test_geaenderte_darstellung_verwerfen_wechselt(self, _warnung, _erfolg):
"""Prüft, dass „Verwerfen“ die geänderte Darstellung aufgibt und das Thema wechselt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="verwerfen"):
_bereich(thema="grau", ziel=layer)._on_thema_gewaehlt(1)
self.assertEqual(bt.thema_des_layers(layer), "grau")
def test_als_stil_speichern_sichert_vor_dem_wechsel(self, _warnung, _erfolg):
"""Prüft, dass „Als Stil speichern“ die Darstellung vor dem Themenwechsel als Stilvorlage sichert."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
bereich._stil_combo = MagicMock()
bereich._stil_combo.currentText.return_value = ""
bereich._stilvorlagen.vorhanden.return_value = False
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="speichern"), \
patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Farbe angepasst", True)):
bereich._on_thema_gewaehlt(1)
self.assertEqual(bereich._stilvorlagen.speichern.call_args[0][:2], (layer, "Farbe angepasst"))
self.assertEqual(bt.thema_des_layers(layer), "grau")
def test_abgebrochenes_speichern_verhindert_den_wechsel(self, _warnung, _erfolg):
"""Prüft, dass das Thema nicht wechselt, wenn „Als Stil speichern“ gewählt, die Namensabfrage aber abgebrochen wird."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
bereich._stil_combo = MagicMock()
bereich._stil_combo.currentText.return_value = ""
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="speichern"), \
patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("", False)):
bereich._on_thema_gewaehlt(1)
bereich._stilvorlagen.speichern.assert_not_called()
self.assertEqual(bt.thema_des_layers(layer), "farbe")
def test_unbekannter_stand_fragt_nach(self, _warnung, _erfolg):
"""Prüft, dass bei einem Layer ohne gemerkten Prüfwert (vor dieser Funktion geladen) vorsichtshalber nachgefragt wird."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]))
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
_bereich(thema="grau", ziel=layer)._on_thema_gewaehlt(1)
self.assertIn("nicht bekannt", frage.call_args[0][1])
self.assertEqual(bt.thema_des_layers(layer), "farbe")
def test_ohne_basemap_layer_nur_vorauswahl(self, warnung, _erfolg):
"""Prüft, dass die Themenwahl ohne Basemap-Layer nur als Vorauswahl für „Basemap hinzufügen“ dient und nichts meldet."""
_bereich(thema="grau", ziel=None)._on_thema_gewaehlt(1)
warnung.assert_not_called()
class TestZielLayer(unittest.TestCase):
"""Auswahl des Basemap-Layers, auf den Thema und Stil wirken."""
def test_ausgewaehlter_basemap_layer_hat_vorrang(self):
"""Prüft, dass der im Layerbaum ausgewählte Basemap-Layer Ziel des Themenwechsels ist, sonst der erste Basemap-Layer."""
bereich = BasemapBereich.__new__(BasemapBereich)
erster, ausgewaehlt = _Layer(bt.quelle(bt.THEMEN["farbe"])), _Layer(bt.quelle(bt.THEMEN["grau"]))
bereich.basemap_layer = MagicMock(return_value=erster)
with patch("sn_basis.ui.widgets.basemap_bereich.get_active_layer", return_value=ausgewaehlt):
self.assertIs(bereich._ziel_layer(), ausgewaehlt)
with patch("sn_basis.ui.widgets.basemap_bereich.get_active_layer", return_value=MagicMock(spec=["name"])):
self.assertIs(bereich._ziel_layer(), erster)
if __name__ == "__main__":
unittest.main()