Files
Plugin_SN_Basis/tests/test_basemap_legende_layout.py
T
DanielandClaude Opus 5.5 5aec62ad3f Autobahn in der Basemap-Legende blau statt grau (#98)
Der Legendenkatalog nannte für die Autobahn nur Kontur und Decker der
getrennten Fahrbahn, die basemap.de nur bis Zoom 13 zeichnet. Bei den
üblichen Druckmaßstäben (Zoom 14 und mehr) griff deshalb der Rückfall
auf den ersten getroffenen Style, die graue Kontur. Jetzt stehen die
Styles für alle Zoombereiche in symbol_stile; inaktive werden wie
bisher übergangen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:41:23 +02:00

305 lines
15 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_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()