""" 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="", 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'<{thema}/>' 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='') b = _Layer(darstellung='') self.assertEqual(bt.pruefwert(a), bt.pruefwert(b)) b.darstellung = '' 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 += "" 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 += "" 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("", 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 += "" 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 += "" 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 += "" 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()