Files
Plugin_SN_Basis/tests/test_basemap_legende_layout.py
T

305 lines
15 KiB
Python
Raw Normal View History

"""
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
2026-10-02 13:20:15 +02:00
self.gleiche_breite = True
def setColumnCount(self, n): self.spalten = n
def setSplitLayer(self, wert): pass
2026-10-02 13:20:15 +02:00
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)
2026-10-02 13:20:15 +02:00
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)
2026-10-02 13:20:15 +02:00
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()