328 lines
16 KiB
Python
328 lines
16 KiB
Python
"""
|
||||
|
|
Tests für modules/basemap_legende.py – Zuordnung, Auswahl und Ablauf der Basemap-Legende (Basis #88).
|
|||
|
|
|
|||
|
|
Die Kachelauswertung und die Layoutausgabe werden durch Fakes ersetzt; die echte Auswertung
|
|||
|
|
gegen basemap.de ist im QGIS-Test geprüft (siehe Pull Request).
|
|||
|
|
"""
|
|||
|
|
import json
|
|||
|
|
import unittest
|
|||
|
|
from unittest.mock import patch
|
|||
|
|
|
|||
|
|
from sn_basis.functions.variable_wrapper import get_variable, set_variable
|
|||
|
|
from sn_basis.modules import basemap_legende as bl
|
|||
|
|
from sn_basis.modules.basemap_legende import (
|
|||
|
|
AUSWAHL_VAR,
|
|||
|
|
AUSWERTEBEREICH_DRUCKBEREICH,
|
|||
|
|
AUSWERTEBEREICH_VAR,
|
|||
|
|
AUSWERTEBEREICH_VERFAHRENSGEBIET,
|
|||
|
|
BasemapLegende,
|
|||
|
|
Katalog,
|
|||
|
|
LEGENDE_ERZEUGEN_VAR,
|
|||
|
|
LegendenEintrag,
|
|||
|
|
ObjektInfo,
|
|||
|
|
StilInfo,
|
|||
|
|
bilde_eintraege,
|
|||
|
|
ermittle_getroffene_stile,
|
|||
|
|
ist_basemap_layer,
|
|||
|
|
legende_erzeugen_aktiv,
|
|||
|
|
lies_auswahl,
|
|||
|
|
lies_auswertebereich,
|
|||
|
|
merke_auswahl,
|
|||
|
|
setze_auswertebereich,
|
|||
|
|
sichtbare_layer_der_karte,
|
|||
|
|
stil_ist_aktiv,
|
|||
|
|
vorauswahl,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
KATALOG = Katalog({
|
|||
|
|
"gruppen": ["Verkehr", "Vegetation"],
|
|||
|
|
"nie_in_legende": ["Hintergrund"],
|
|||
|
|
"nie_in_legende_praefixe": ["Nummer_"],
|
|||
|
|
"eintraege": [
|
|||
|
|
{"schluessel": "kreis", "bezeichnung": "Kreisstraße", "gruppe": "Verkehr",
|
|||
|
|
"treffer_stile": ["Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"],
|
|||
|
|
"symbol_stile": ["Kontur_Kreisstr", "Decker_Kreisstr"]},
|
|||
|
|
{"schluessel": "wald", "bezeichnung": "Wald", "gruppe": "Vegetation",
|
|||
|
|
"treffer_stile": ["VegetationsF_Wald"], "symbol_stile": ["VegetationsF_Wald"]},
|
|||
|
|
{"schluessel": "weg", "bezeichnung": "Wirtschaftsweg", "gruppe": "Verkehr",
|
|||
|
|
"treffer_stile": ["Kontur_Wirtschaftsweg"], "symbol_stile": ["Kontur_Wirtschaftsweg"]},
|
|||
|
|
],
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _filter_ueber_attribute(filter_text, objekt):
|
|||
|
|
"""Einfache Filterprüfung für Tests: Filter der Form klasse=Wert."""
|
|||
|
|
feld, wert = filter_text.split("=")
|
|||
|
|
return objekt.attribute.get(feld) == wert
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _leere_variablen():
|
|||
|
|
for schluessel in (AUSWAHL_VAR, AUSWERTEBEREICH_VAR, LEGENDE_ERZEUGEN_VAR):
|
|||
|
|
set_variable(schluessel, "", scope="project")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestStileUndEintraege(unittest.TestCase):
|
|||
|
|
|
|||
|
|
def test_stil_aktiv_nur_eingeschaltet_und_im_zoombereich(self):
|
|||
|
|
"""Prüft, dass ein Style nur aktiv ist, wenn er eingeschaltet ist und die Zoomstufe im Bereich liegt (-1 = unbegrenzt)."""
|
|||
|
|
stil = StilInfo("A", "L", 1, min_zoom=11, max_zoom=14)
|
|||
|
|
self.assertTrue(stil_ist_aktiv(stil, 11))
|
|||
|
|
self.assertFalse(stil_ist_aktiv(stil, 15))
|
|||
|
|
self.assertFalse(stil_ist_aktiv(StilInfo("A", "L", 1, aktiv=False), 12))
|
|||
|
|
self.assertTrue(stil_ist_aktiv(StilInfo("A", "L", 1), 20))
|
|||
|
|
|
|||
|
|
def test_getroffene_stile_pruefen_layer_geometrie_zoom_und_filter(self):
|
|||
|
|
"""Prüft, dass ein Style nur Objekte seines Quell-Layers und Geometrietyps zählt, deren Filter zutrifft, und nur bei passender Zoomstufe."""
|
|||
|
|
stile = [
|
|||
|
|
StilInfo("Decker_Kreisstr", "Verkehrslinie", 1, filter="klasse=Kreisstraße"),
|
|||
|
|
StilInfo("Decker_Bundesstr", "Verkehrslinie", 1, filter="klasse=Bundesstraße"),
|
|||
|
|
StilInfo("VegetationsF_Wald", "Vegetationsflaeche", 2, filter="klasse=Wald"),
|
|||
|
|
StilInfo("Nur_klein", "Verkehrslinie", 1, max_zoom=10),
|
|||
|
|
StilInfo("Alle_Layer", "", 2),
|
|||
|
|
]
|
|||
|
|
objekte = [
|
|||
|
|
ObjektInfo("Verkehrslinie", 1, {"klasse": "Kreisstraße"}),
|
|||
|
|
ObjektInfo("Vegetationsflaeche", 2, {"klasse": "Wald"}),
|
|||
|
|
ObjektInfo("Vegetationsflaeche", 1, {"klasse": "Wald"}), # Linie: passt nicht zum Flächenstyle
|
|||
|
|
]
|
|||
|
|
getroffen = ermittle_getroffene_stile(objekte, stile, 15, _filter_ueber_attribute)
|
|||
|
|
self.assertEqual(getroffen, {"Decker_Kreisstr": 1, "VegetationsF_Wald": 1, "Alle_Layer": 1})
|
|||
|
|
|
|||
|
|
def test_eintraege_fassen_styles_zusammen_und_sortieren_nach_gruppe(self):
|
|||
|
|
"""Prüft, dass mehrere Styles eines Katalogeintrags einen Eintrag ergeben und die Einträge nach Gruppen- und Katalogreihenfolge sortiert sind."""
|
|||
|
|
eintraege = bilde_eintraege(
|
|||
|
|
["VegetationsF_Wald", "Kontur_Wirtschaftsweg", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"], KATALOG)
|
|||
|
|
self.assertEqual([e.schluessel for e in eintraege], ["kreis", "weg", "wald"])
|
|||
|
|
self.assertEqual(eintraege[0].treffer, ["Decker_Kreisstr", "Bruecke_Kreisstr_Decker"])
|
|||
|
|
self.assertEqual(eintraege[0].symbol_stile, ["Kontur_Kreisstr", "Decker_Kreisstr"])
|
|||
|
|
|
|||
|
|
def test_ausgeschlossene_und_unbekannte_styles(self):
|
|||
|
|
"""Prüft, dass ausgeschlossene Styles entfallen und unbekannte Styles als nicht bekannter Eintrag in „Sonstiges“ landen."""
|
|||
|
|
eintraege = bilde_eintraege(["Hintergrund", "Nummer_B6", "Eigener_Style"], KATALOG)
|
|||
|
|
self.assertEqual(len(eintraege), 1)
|
|||
|
|
self.assertEqual(eintraege[0].schluessel, "stil:Eigener_Style")
|
|||
|
|
self.assertEqual(eintraege[0].gruppe, "Sonstiges")
|
|||
|
|
self.assertFalse(eintraege[0].bekannt)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestAuswahlUndEinstellungen(unittest.TestCase):
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
_leere_variablen()
|
|||
|
|
|
|||
|
|
def tearDown(self):
|
|||
|
|
_leere_variablen()
|
|||
|
|
|
|||
|
|
def test_vorauswahl_bekannte_an_unbekannte_aus(self):
|
|||
|
|
"""Prüft, dass ohne gespeicherte Auswahl Katalogeinträge vorausgewählt und unbekannte Styles abgewählt sind."""
|
|||
|
|
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
|
|||
|
|
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
|
|||
|
|
self.assertEqual(vorauswahl(eintraege), {"kreis"})
|
|||
|
|
|
|||
|
|
def test_auswahl_wird_gemerkt_und_vorbelegt(self):
|
|||
|
|
"""Prüft, dass ab- und angewählte Einträge in der Projektvariable gespeichert und beim nächsten Mal so vorbelegt werden."""
|
|||
|
|
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
|
|||
|
|
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
|
|||
|
|
merke_auswahl(eintraege, ["stil:X"])
|
|||
|
|
|
|||
|
|
self.assertEqual(lies_auswahl(), {"ausgeblendet": ["kreis"], "eingeblendet": ["stil:X"]})
|
|||
|
|
self.assertEqual(vorauswahl(eintraege, lies_auswahl()), {"stil:X"})
|
|||
|
|
|
|||
|
|
def test_merken_ergaenzt_die_bisherige_auswahl(self):
|
|||
|
|
"""Prüft, dass das Merken die Auswahl nicht angezeigter Einträge aus früheren Läufen behält."""
|
|||
|
|
merke_auswahl([LegendenEintrag("wald", "Wald", "Vegetation", [])], [])
|
|||
|
|
merke_auswahl([LegendenEintrag("kreis", "Kreisstraße", "Verkehr", [])], ["kreis"])
|
|||
|
|
self.assertEqual(lies_auswahl()["ausgeblendet"], ["wald"])
|
|||
|
|
|
|||
|
|
def test_kaputte_projektvariable_ergibt_leere_auswahl(self):
|
|||
|
|
"""Prüft, dass eine nicht lesbare Projektvariable als leere Auswahl gilt."""
|
|||
|
|
set_variable(AUSWAHL_VAR, "{kein json", scope="project")
|
|||
|
|
self.assertEqual(lies_auswahl(), {"ausgeblendet": [], "eingeblendet": []})
|
|||
|
|
|
|||
|
|
def test_auswertebereich_standard_und_umstellung(self):
|
|||
|
|
"""Prüft, dass der Auswertebereich standardmäßig das Verfahrensgebiet ist, umgestellt werden kann und unbekannte Werte abgewiesen werden."""
|
|||
|
|
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_VERFAHRENSGEBIET)
|
|||
|
|
setze_auswertebereich(AUSWERTEBEREICH_DRUCKBEREICH)
|
|||
|
|
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_DRUCKBEREICH)
|
|||
|
|
with self.assertRaises(ValueError):
|
|||
|
|
setze_auswertebereich("irgendwo")
|
|||
|
|
|
|||
|
|
def test_legende_erzeugen_standard_an(self):
|
|||
|
|
"""Prüft, dass das Erzeugen der Basemap-Legende standardmäßig eingeschaltet ist und abgeschaltet werden kann."""
|
|||
|
|
self.assertTrue(legende_erzeugen_aktiv())
|
|||
|
|
bl.setze_legende_erzeugen(False)
|
|||
|
|
self.assertFalse(legende_erzeugen_aktiv())
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeVektorkachelLayer:
|
|||
|
|
def __init__(self, quelle="", name="Layer"):
|
|||
|
|
self._quelle, self._name = quelle, name
|
|||
|
|
|
|||
|
|
def tileMatrixSet(self):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def source(self):
|
|||
|
|
return self._quelle
|
|||
|
|
|
|||
|
|
def name(self):
|
|||
|
|
return self._name
|
|||
|
|
|
|||
|
|
def crs(self):
|
|||
|
|
return "EPSG:3857"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeVektorLayer:
|
|||
|
|
def source(self):
|
|||
|
|
return "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/wms"
|
|||
|
|
|
|||
|
|
def name(self):
|
|||
|
|
return "Basemap.de WMS"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLayerErkennung(unittest.TestCase):
|
|||
|
|
|
|||
|
|
def test_erkennung_ueber_quelle_und_namen(self):
|
|||
|
|
"""Prüft, dass der Basemap-Layer an Vektorkacheln mit basemap.de-Quelle erkannt wird, der Name nur als Rückfall dient und andere Layertypen nicht zählen."""
|
|||
|
|
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer(
|
|||
|
|
"type=xyz&url=https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/{z}/{x}/{y}.pbf")))
|
|||
|
|
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer("type=mbtiles", "Basemap.de - Farbe")))
|
|||
|
|
self.assertFalse(ist_basemap_layer(_FakeVektorkachelLayer("type=xyz&url=https://andere.de", "OSM")))
|
|||
|
|
self.assertFalse(ist_basemap_layer(_FakeVektorLayer()))
|
|||
|
|
self.assertFalse(ist_basemap_layer(None))
|
|||
|
|
|
|||
|
|
def test_sichtbare_layer_aus_thema_feste_liste_oder_layerbaum(self):
|
|||
|
|
"""Prüft, dass die Layer der Karte aus dem Kartenthema, sonst aus der festen Layerliste, sonst aus dem sichtbaren Layerbaum kommen."""
|
|||
|
|
class Karte:
|
|||
|
|
def __init__(self, thema="", feste=None):
|
|||
|
|
self._thema, self._feste = thema, feste or []
|
|||
|
|
def followVisibilityPreset(self): return bool(self._thema)
|
|||
|
|
def followVisibilityPresetName(self): return self._thema
|
|||
|
|
def keepLayerSet(self): return bool(self._feste)
|
|||
|
|
def layers(self): return self._feste
|
|||
|
|
|
|||
|
|
class Knoten:
|
|||
|
|
def __init__(self, layer, sichtbar): self._layer, self._sichtbar = layer, sichtbar
|
|||
|
|
def layer(self): return self._layer
|
|||
|
|
def isVisible(self): return self._sichtbar
|
|||
|
|
|
|||
|
|
class Projekt:
|
|||
|
|
def mapThemeCollection(self):
|
|||
|
|
return type("T", (), {"mapThemeVisibleLayers": lambda s, t: ["thema-" + t]})()
|
|||
|
|
def layerTreeRoot(self):
|
|||
|
|
return type("W", (), {"findLayers": lambda s: [Knoten("a", True), Knoten("b", False)]})()
|
|||
|
|
|
|||
|
|
self.assertEqual(sichtbare_layer_der_karte(Karte(thema="Plan"), Projekt()), ["thema-Plan"])
|
|||
|
|
self.assertEqual(sichtbare_layer_der_karte(Karte(feste=["x"]), Projekt()), ["x"])
|
|||
|
|
self.assertEqual(sichtbare_layer_der_karte(Karte(), Projekt()), ["a"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeAusgabe:
|
|||
|
|
"""Ersatz für basemap_legende_layout im Ablauftest."""
|
|||
|
|
|
|||
|
|
def __init__(self, karte):
|
|||
|
|
self.karte = karte
|
|||
|
|
self.geschrieben = None
|
|||
|
|
|
|||
|
|
def hauptkarte(self, layout):
|
|||
|
|
return self.karte
|
|||
|
|
|
|||
|
|
def baue_symbole(self, layer, eintraege, zoom, vtz):
|
|||
|
|
return {e.schluessel: f"symbol-{e.schluessel}" for e in eintraege}
|
|||
|
|
|
|||
|
|
def vorschau_icon(self, symbol):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def schreibe_legende(self, layout, karte, eintraege, symbole, projekt, *, bereich, massstab):
|
|||
|
|
self.geschrieben = {"eintraege": [e.schluessel for e in eintraege], "bereich": bereich, "massstab": massstab}
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeKarte:
|
|||
|
|
def scale(self):
|
|||
|
|
return 5000.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestAblauf(unittest.TestCase):
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
_leere_variablen()
|
|||
|
|
self.dialog_aufrufe = []
|
|||
|
|
self.meldungen = []
|
|||
|
|
|
|||
|
|
def tearDown(self):
|
|||
|
|
_leere_variablen()
|
|||
|
|
|
|||
|
|
def _legende(self, dialog_ergebnis=("kreis",)):
|
|||
|
|
def dialog(titel, hinweis, eintraege, vorauswahl):
|
|||
|
|
self.dialog_aufrufe.append((eintraege, set(vorauswahl)))
|
|||
|
|
return None if dialog_ergebnis is None else list(dialog_ergebnis)
|
|||
|
|
return BasemapLegende(project=object(), katalog=KATALOG, dialog=dialog,
|
|||
|
|
fortschritt_fabrik=lambda *a: None, bestaetigen=lambda *a: True,
|
|||
|
|
melden=lambda titel, text: self.meldungen.append(text))
|
|||
|
|
|
|||
|
|
def _starte(self, legende, basemap=True, genutzt=AUSWERTEBEREICH_VERFAHRENSGEBIET, eintraege=None):
|
|||
|
|
ausgabe = _FakeAusgabe(_FakeKarte())
|
|||
|
|
layer = [_FakeVektorkachelLayer("gdz_basemapde_vektor", "Basemap.de - Farbe")] if basemap else []
|
|||
|
|
eintraege = eintraege if eintraege is not None else bilde_eintraege(
|
|||
|
|
["Decker_Kreisstr", "VegetationsF_Wald"], KATALOG)
|
|||
|
|
with patch.dict("sys.modules", {"sn_basis.modules.basemap_legende_layout": ausgabe}), \
|
|||
|
|
patch("sn_basis.modules.basemap_legende.sichtbare_layer_der_karte", return_value=layer), \
|
|||
|
|
patch("sn_basis.modules.basemap_legende.auswertegeometrie", return_value=("geometrie", genutzt)), \
|
|||
|
|
patch("sn_basis.modules.basemap_legende.zoomstufen", return_value=(15, 16, 15.8)), \
|
|||
|
|
patch.object(BasemapLegende, "ermittle_eintraege", return_value=eintraege):
|
|||
|
|
import sn_basis.modules as paket
|
|||
|
|
with patch.object(paket, "basemap_legende_layout", ausgabe, create=True):
|
|||
|
|
ergebnis = legende.aktualisiere(layout="layout")
|
|||
|
|
return ergebnis, ausgabe
|
|||
|
|
|
|||
|
|
def test_ohne_basemap_layer_passiert_nichts(self):
|
|||
|
|
"""Prüft, dass ohne sichtbaren Basemap-Layer weder Dialog noch Legende entstehen."""
|
|||
|
|
ergebnis, ausgabe = self._starte(self._legende(), basemap=False)
|
|||
|
|
self.assertEqual(ergebnis["status"], "kein_layer")
|
|||
|
|
self.assertEqual(self.dialog_aufrufe, [])
|
|||
|
|
self.assertIsNone(ausgabe.geschrieben)
|
|||
|
|
|
|||
|
|
def test_gewaehlte_eintraege_werden_geschrieben_und_gemerkt(self):
|
|||
|
|
"""Prüft, dass nur die im Dialog gewählten Einträge in die Legende geschrieben und die Auswahl gespeichert wird."""
|
|||
|
|
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=("kreis",)))
|
|||
|
|
self.assertEqual(ergebnis["status"], "ok")
|
|||
|
|
self.assertEqual(ausgabe.geschrieben["eintraege"], ["kreis"])
|
|||
|
|
self.assertEqual(ausgabe.geschrieben["massstab"], 5000.0)
|
|||
|
|
eintraege, vorbelegt = self.dialog_aufrufe[0]
|
|||
|
|
self.assertEqual([e["gruppe"] for e in eintraege], ["Verkehr", "Vegetation"])
|
|||
|
|
self.assertEqual(vorbelegt, {"kreis", "wald"})
|
|||
|
|
self.assertEqual(json.loads(get_variable(AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"])
|
|||
|
|
|
|||
|
|
def test_abbruch_im_dialog_aendert_nichts(self):
|
|||
|
|
"""Prüft, dass ein Abbruch im Auswahldialog die Legende nicht verändert und keine Auswahl speichert."""
|
|||
|
|
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=None))
|
|||
|
|
self.assertEqual(ergebnis["status"], "abgebrochen")
|
|||
|
|
self.assertIsNone(ausgabe.geschrieben)
|
|||
|
|
self.assertFalse(get_variable(AUSWAHL_VAR, scope="project"))
|
|||
|
|
|
|||
|
|
def test_rueckfall_auf_druckbereich_wird_gemeldet(self):
|
|||
|
|
"""Prüft, dass ohne Verfahrensgebiet auf den Druckbereich zurückgefallen und das gemeldet wird."""
|
|||
|
|
ergebnis, ausgabe = self._starte(self._legende(), genutzt=AUSWERTEBEREICH_DRUCKBEREICH)
|
|||
|
|
self.assertEqual(ergebnis["status"], "ok")
|
|||
|
|
self.assertEqual(ausgabe.geschrieben["bereich"], AUSWERTEBEREICH_DRUCKBEREICH)
|
|||
|
|
self.assertTrue(any("Druckbereich" in m for m in self.meldungen))
|
|||
|
|
|
|||
|
|
def test_ohne_objekte_keine_legende(self):
|
|||
|
|
"""Prüft, dass ohne Basemap-Objekte im Auswertebereich weder Dialog noch Legende entstehen."""
|
|||
|
|
ergebnis, ausgabe = self._starte(self._legende(), eintraege=[])
|
|||
|
|
self.assertEqual(ergebnis["status"], "keine_eintraege")
|
|||
|
|
self.assertEqual(self.dialog_aufrufe, [])
|
|||
|
|
self.assertIsNone(ausgabe.geschrieben)
|
|||
|
|
|
|||
|
|
def test_viele_kacheln_werden_vorher_bestaetigt(self):
|
|||
|
|
"""Prüft, dass vor einer Auswertung mit sehr vielen Kacheln nachgefragt wird und eine Ablehnung die Auswertung abbricht."""
|
|||
|
|
legende = BasemapLegende(project=object(), katalog=KATALOG, dialog=lambda *a: [],
|
|||
|
|
fortschritt_fabrik=lambda *a: self.fail("keine Auswertung erwartet"),
|
|||
|
|
bestaetigen=lambda *a: False, melden=lambda *a: None)
|
|||
|
|
with patch("sn_basis.modules.basemap_legende.anzahl_kacheln", return_value=bl.KACHELN_NACHFRAGEN_AB):
|
|||
|
|
self.assertIsNone(legende.ermittle_eintraege(object(), object(), 5000.0))
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|