""" Tests für modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout (Basis #88). Layout, Legende und Projekt sind Fakes. Aussehen der Symbole und Platzierung im echten Layout sind im QGIS-Test geprüft (siehe Pull Request). """ import json import unittest from unittest.mock import patch from sn_basis.modules import basemap_legende_layout as bll from sn_basis.modules.basemap_legende import Katalog, LegendenEintrag, bilde_eintraege def _wert(objekt, name): """Liest x/y/width/height – im Mock-Modus Attribute, in QGIS Methoden.""" wert = getattr(objekt, name) return wert() if callable(wert) else wert class _Rechteck: def __init__(self, x, y, b, h): self.w = (x, y, b, h) def x(self): return self.w[0] def y(self): return self.w[1] def width(self): return self.w[2] def height(self): return self.w[3] class _Element: def __init__(self, x=0.0, y=0.0, b=0.0, h=0.0): self.rechteck = _Rechteck(x, y, b, h) def sceneBoundingRect(self): return self.rechteck class _Seite: def __init__(self, breite): self._breite = breite def pageSize(self): return type("S", (), {"width": lambda s: self._breite})() class _Layout: def __init__(self, elemente=None, breite=841.0, name="Plan"): self.elemente = dict(elemente or {}) self.eigenschaften = {} self.entfernt = [] self._breite, self._name = breite, name def itemById(self, element_id): return self.elemente.get(element_id) def items(self): return list(self.elemente.values()) def setCustomProperty(self, k, v): self.eigenschaften[k] = v def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard) def removeLayoutItem(self, element): self.entfernt.append(element) def name(self): return self._name def pageCollection(self): return type("P", (), {"page": lambda s, i: _Seite(self._breite)})() class _Legende(_Element): """Legende, deren Platzbedarf je Spaltenzahl vorgegeben ist.""" def __init__(self): super().__init__() self.spalten = 1 self.bewegt = None self.groesse = None self.gleiche_breite = True def setColumnCount(self, n): self.spalten = n def setSplitLayer(self, wert): pass def setEqualColumnWidth(self, wert): self.gleiche_breite = wert def setReferencePoint(self, punkt): pass def attemptMove(self, punkt): self.bewegt = punkt def attemptResize(self, groesse): self.groesse = groesse class TestKennzeichenUndKarte(unittest.TestCase): def test_layout_kennzeichen_setzen_und_erkennen(self): """Prüft, dass ein markiertes Layout als Nutzer der Basemap-Legende erkannt und in der Projektliste geführt wird.""" markiert, normal = _Layout(name="§41 Plan"), _Layout(name="Baufreigabe") bll.markiere_layout(markiert) self.assertTrue(bll.nutzt_basemap_legende(markiert)) self.assertFalse(bll.nutzt_basemap_legende(normal)) projekt = type("P", (), {"layoutManager": lambda s: type("M", (), { "printLayouts": lambda s: [markiert, normal]})()})() self.assertEqual(bll.layouts_mit_basemap_legende(projekt), ["§41 Plan"]) def test_hauptkarte_ueber_id(self): """Prüft, dass die Karte mit der ID „Hauptkarte“ gefunden wird.""" karte = _Element() self.assertIs(bll.hauptkarte(_Layout({"Hauptkarte": karte})), karte) self.assertIsNone(bll.hauptkarte(_Layout())) class TestSymbolwahl(unittest.TestCase): def test_symbolnamen_aus_aktiven_symbol_stilen_in_renderer_reihenfolge(self): """Prüft, dass die Symbol-Styles des Katalogs in Zeichenreihenfolge genommen und inaktive übergangen werden.""" eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr", "Kontur_Kreisstr"], treffer=["Bruecke_Kreisstr_Decker"]) aktive = {"Kontur_Kreisstr": 3, "Decker_Kreisstr": 7, "Bruecke_Kreisstr_Decker": 9} self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Kontur_Kreisstr", "Decker_Kreisstr"]) def test_ohne_aktive_symbol_stile_erster_getroffener_style(self): """Prüft, dass ohne aktiven Symbol-Style der erste tatsächlich getroffene Style das Legendensymbol liefert.""" eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr"], treffer=["Bruecke_Kreisstr_Decker", "Tunnel_Kreisstr"]) aktive = {"Bruecke_Kreisstr_Decker": 9, "Tunnel_Kreisstr": 2} self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Bruecke_Kreisstr_Decker"]) class TestAutobahnSymbol(unittest.TestCase): """Autobahn-Symbol aus dem ausgelieferten Katalog (Basis #98). Zoombereiche und Renderer-Reihenfolge nach basemap.de ``bm_web_col`` Version 8: Die Styles ``*_Autobahn_getrennt`` gelten nur bis Zoom 13, ab Zoom 14 ``*_Autobahn_getrennt_Fahrbahnachse``, unter Zoom 11 nur ``Decker_Autobahn_getrennt_ohne_Tunnel_Bruecke``. """ REIHENFOLGE = { "Staumauer_Staudamm_Autobahn": 140, "Kontur_Autobahn_Abfahrt": 152, "Kontur_Autobahn_getrennt": 169, "Kontur_Autobahn_getrennt_Fahrbahnachse": 170, "Decker_Autobahn_Abfahrt_einblenden": 177, "Decker_Autobahn_Abfahrt": 183, "Decker_Autobahn_getrennt_ohne_Tunnel_Bruecke": 203, "Decker_Autobahn_getrennt": 204, "Decker_Autobahn_getrennt_Fahrbahnachse": 205, "Mittellinie_Autobahn": 206, } def _symbolnamen(self, aktiv: list[str]) -> list[str]: katalog = Katalog.laden() eintrag, = bilde_eintraege(aktiv, katalog) return bll.waehle_symbolnamen(eintrag, {name: self.REIHENFOLGE[name] for name in aktiv}) def test_druckmassstab_ab_zoom_14_blauer_decker_auf_grauer_kontur(self): """Prüft, dass das Autobahn-Symbol ab Zoom 14 aus grauer Kontur und blauem Decker der Fahrbahnachse besteht statt nur aus einem grauen Style.""" aktiv = ["Staumauer_Staudamm_Autobahn", "Kontur_Autobahn_Abfahrt", "Kontur_Autobahn_getrennt_Fahrbahnachse", "Decker_Autobahn_Abfahrt", "Decker_Autobahn_getrennt_Fahrbahnachse", "Mittellinie_Autobahn"] self.assertEqual(self._symbolnamen(aktiv), ["Kontur_Autobahn_getrennt_Fahrbahnachse", "Decker_Autobahn_getrennt_Fahrbahnachse"]) def test_zoom_11_bis_13_bisherige_kontur_und_decker(self): """Prüft, dass das Autobahn-Symbol bei Zoom 11 bis 13 weiterhin aus Kontur und Decker der getrennten Fahrbahn besteht.""" aktiv = ["Staumauer_Staudamm_Autobahn", "Kontur_Autobahn_Abfahrt", "Kontur_Autobahn_getrennt", "Decker_Autobahn_Abfahrt", "Decker_Autobahn_getrennt", "Mittellinie_Autobahn"] self.assertEqual(self._symbolnamen(aktiv), ["Kontur_Autobahn_getrennt", "Decker_Autobahn_getrennt"]) def test_unter_zoom_11_blauer_decker(self): """Prüft, dass das Autobahn-Symbol unter Zoom 11 den blauen Decker der Übersichtsdarstellung nutzt.""" aktiv = ["Decker_Autobahn_Abfahrt_einblenden", "Decker_Autobahn_getrennt_ohne_Tunnel_Bruecke"] self.assertEqual(self._symbolnamen(aktiv), ["Decker_Autobahn_getrennt_ohne_Tunnel_Bruecke"]) class _Symbol: def __init__(self, typ): self._typ = typ def type(self): return self._typ class _Projekt: def __init__(self): self.layer = {} def addMapLayer(self, layer, in_baum=True): layer._in_baum = in_baum self.layer[layer.id()] = layer def mapLayer(self, layer_id): return self.layer.get(layer_id) def removeMapLayer(self, layer_id): self.layer.pop(layer_id, None) class _Speicherlayer: zaehler = 0 def __init__(self, uri, name, provider): _Speicherlayer.zaehler += 1 self.uri, self._name, self._id = uri, name, f"hl{_Speicherlayer.zaehler}" self.eigenschaften = {} self.renderer = None def id(self): return self._id def setRenderer(self, renderer): self.renderer = renderer def setCustomProperty(self, k, v): self.eigenschaften[k] = v class TestHilfslayer(unittest.TestCase): @patch("sn_basis.modules.basemap_legende_layout.QgsVectorLayer", _Speicherlayer) def test_je_gruppe_und_geometrietyp_ein_leerer_hilfslayer(self): """Prüft, dass je Gruppe und Geometrietyp ein Hilfslayer außerhalb des Layerbaums entsteht, Flächen vor Linien, ohne Rückfrage beim Schließen.""" eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []), LegendenEintrag("platz", "Platz", "Verkehr", []), LegendenEintrag("wald", "Wald", "Vegetation", []), LegendenEintrag("ohne", "Ohne Symbol", "Vegetation", [])] symbole = {"kreis": _Symbol(1), "platz": _Symbol(2), "wald": _Symbol(2)} projekt = _Projekt() gruppen = bll.erzeuge_hilfslayer(projekt, "Plan", "EPSG:25833", eintraege, symbole) self.assertEqual([g for g, _ in gruppen], ["Verkehr", "Vegetation"]) verkehr = gruppen[0][1] self.assertTrue(verkehr[0].uri.startswith("Polygon?crs=EPSG:25833")) self.assertTrue(verkehr[1].uri.startswith("LineString?")) self.assertEqual([k.label() for k in verkehr[1].renderer.categories()], ["Kreisstraße"]) self.assertEqual(len(gruppen[1][1]), 1) for layer in projekt.layer.values(): self.assertFalse(layer._in_baum) self.assertEqual(layer.eigenschaften["skipMemoryLayersCheck"], 1) self.assertEqual(layer.eigenschaften[bll.HILFSLAYER_KENNUNG], "Plan") def test_alte_hilfslayer_werden_entfernt(self): """Prüft, dass die Hilfslayer einer früheren Basemap-Legende des Layouts aus dem Projekt entfernt werden.""" projekt = _Projekt() alt = _Speicherlayer("Point", "alt", "memory") projekt.addMapLayer(alt, False) layout = _Layout() layout.setCustomProperty(bll.LAYOUT_HILFSLAYER, f"{alt.id()},gibt-es-nicht") bll.entferne_hilfslayer(projekt, layout) self.assertEqual(projekt.layer, {}) def test_ohne_eintraege_wird_vorhandene_basemap_legende_entfernt(self): """Prüft, dass ohne gewählte Einträge eine vorhandene Basemap-Legende entfernt und die Auswahl am Layout vermerkt wird.""" vorhandene = _Legende() layout = _Layout({bll.LEGENDE_ID: vorhandene}) hinweis = bll.schreibe_legende(layout, _Element(), [], {}, _Projekt(), bereich="druckbereich", massstab=2500) self.assertEqual(hinweis, "") self.assertEqual(layout.entfernt, [vorhandene]) self.assertEqual(layout.eigenschaften[bll.LAYOUT_BEREICH], "druckbereich") self.assertEqual(json.loads(layout.eigenschaften[bll.LAYOUT_EINTRAEGE]), []) self.assertTrue(bll.nutzt_basemap_legende(layout)) class TestPlatzierung(unittest.TestCase): def _layout(self, breite=841.0): return _Layout({ bll.HAUPTLEGENDE_ID: _Element(380, 10, 180, 50), bll.QUELLENANGABE_ID: _Element(380, 198, 180, 20), bll.HAUPTKARTE_ID: _Element(10, 10, 360, 310), }, breite=breite) def _platziere(self, layout, bedarf): legende = _Legende() with patch.object(bll, "mindestgroesse", side_effect=lambda l, lay, n: bedarf[n]): hinweis = bll.platziere(layout, legende) return legende, hinweis def test_einspaltig_ueber_der_quellenangabe(self): """Prüft, dass die Basemap-Legende einspaltig unten an der Quellenangabe verankert wird, wenn der Platz reicht.""" legende, hinweis = self._platziere(self._layout(), {1: (100.0, 120.0)}) self.assertEqual(hinweis, "") self.assertEqual(legende.spalten, 1) self.assertEqual((_wert(legende.bewegt, "x"), _wert(legende.bewegt, "y")), (380, 198 - bll.ABSTAND_MM)) self.assertEqual((_wert(legende.groesse, "width"), _wert(legende.groesse, "height")), (100.0, 120.0)) def test_mehrspaltig_wenn_hoehe_nicht_reicht(self): """Prüft, dass die Legende zwei- oder dreispaltig wird, wenn sie einspaltig nicht zwischen Hauptlegende und Quellenangabe passt.""" legende, hinweis = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)}) self.assertEqual(hinweis, "") self.assertEqual(legende.spalten, 3) def test_spalten_ungleich_breit(self): """Prüft, dass die Basemap-Legende ungleich breite Spalten bekommt (#96).""" legende, _ = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)}) self.assertFalse(legende.gleiche_breite) def test_mindestgroesse_mit_ungleichen_spalten_und_umbruch(self): """Prüft, dass der Platzbedarf mit ungleich breiten Spalten und Textumbruch berechnet wird (#96).""" class Einstellungen: def __init__(self): self.werte = {} def __getattr__(self, name): return lambda wert: self.werte.__setitem__(name, wert) einstellungen = Einstellungen() legende = type("L", (), {"legendSettings": lambda s: einstellungen, "model": lambda s: None})() renderer = type("R", (), {"minimumSize": lambda s, k: type("G", (), {"width": lambda s: 80.0, "height": lambda s: 40.0})()}) with patch.object(bll, "QgsLayoutUtils", type("U", (), {"createRenderContextForLayout": staticmethod(lambda l, p: object())})), patch.object(bll, "QgsLegendRenderer", lambda modell, einst: renderer()): self.assertEqual(bll.mindestgroesse(legende, object(), 2), (80.0, 40.0)) self.assertEqual(einstellungen.werte["setColumnCount"], 2) self.assertFalse(einstellungen.werte["setEqualColumnWidth"]) self.assertEqual(einstellungen.werte["setAutoWrapLinesAfter"], bll.TEXTUMBRUCH_MM) def test_neue_basemap_legende_mindestens_so_breit_wie_kartenschild(self): """Prüft, dass eine neu angelegte Basemap-Legende die Breite des Kartenschilds als Mindestbreite bekommt.""" class Element: def __init__(self, layout): self.mindestbreite = None def __getattr__(self, name): return lambda *a: None def setMinimumSize(self, groesse): self.mindestbreite = groesse.width() if callable(groesse.width) else groesse.width layout = type("L", (), {"addLayoutItem": lambda s, e: None})() with patch.object(bll, "QgsLayoutItemLegend", Element): legende = bll._neues_legendenelement(layout) self.assertEqual(legende.mindestbreite, 180.0) def test_titel_zweizeilig(self): """Prüft, dass der Titel der Basemap-Legende zweizeilig ist, damit er die Mindestbreite nicht bestimmt (#96).""" self.assertEqual(bll.LEGENDE_TITEL.splitlines(), ["Kartengrundlage", "(basemap.de)"]) def test_nie_breiter_als_der_seitenrand_mit_hinweis(self): """Prüft, dass keine Spaltenzahl gewählt wird, die über den Seitenrand ragt, und dann ein Hinweis kommt.""" legende, hinweis = self._platziere(self._layout(breite=600.0), {1: (100.0, 300.0), 2: (200.0, 160.0), 3: (300.0, 100.0)}) self.assertEqual(legende.spalten, 2) self.assertIn("mehr Platz", hinweis) if __name__ == "__main__": unittest.main()