"""Tests für sn_basis/modules/Datenschreiber.py (Schreib-/Ladepipeline, Issue #63).""" import gc import json import shutil import sqlite3 import tempfile import unittest from pathlib import Path from typing import Any, List, cast from unittest.mock import patch from sn_basis.modules.Datenschreiber import Datenschreiber from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class _AufzeichnenderPruefmanager: def __init__(self) -> None: self.ergebnisse: List[Any] = [] def verarbeite(self, ergebnis: Any): self.ergebnisse.append(ergebnis) return ergebnis class _FakeLayer: def __init__(self, valid: bool = True) -> None: self._valid = valid def isValid(self) -> bool: return self._valid def loadNamedStyle(self, path: str): return True, "" def triggerRepaint(self) -> None: return None def dataProvider(self): return None def _make_schreiber(gpkg_path: str = "C:/tmp/ziel.gpkg") -> tuple[Datenschreiber, _AufzeichnenderPruefmanager]: pruefmanager = _AufzeichnenderPruefmanager() return Datenschreiber(pruefmanager=cast(Any, pruefmanager), gpkg_path=gpkg_path), pruefmanager class WriteLayerToGpkgTests(unittest.TestCase): def test_ungueltiger_layer_liefert_fehlermeldung(self): """Prüft, dass ein ungültiger Layer eine Fehlermeldung statt eines Absturzes liefert.""" schreiber, _pm = _make_schreiber() fehler = schreiber._write_layer_to_gpkg(layer_name="test", layer=_FakeLayer(valid=False)) self.assertIsNotNone(fehler) def test_gueltiger_layer_wird_ohne_fehler_geschrieben(self): """Prüft, dass ein gültiger Layer im Mock-Modus ohne Fehlermeldung geschrieben wird.""" schreiber, _pm = _make_schreiber() fehler = schreiber._write_layer_to_gpkg(layer_name="test", layer=_FakeLayer(valid=True)) self.assertIsNone(fehler) class ResolveStylePathTests(unittest.TestCase): def test_leerer_stilpfad_liefert_none(self): """Prüft, dass ein leerer oder fehlender Stilpfad None liefert.""" schreiber, _pm = _make_schreiber() self.assertIsNone(schreiber._resolve_style_path(None)) self.assertIsNone(schreiber._resolve_style_path(" ")) def test_absoluter_vorhandener_stilpfad_wird_aufgeloest(self): """Prüft, dass ein absoluter, vorhandener Stilpfad normalisiert und unverändert erkannt wird.""" schreiber, _pm = _make_schreiber() with tempfile.TemporaryDirectory() as tmp: stil_datei = Path(tmp) / "stil.qml" stil_datei.write_text("", encoding="utf-8") ergebnis = schreiber._resolve_style_path(str(stil_datei)) self.assertIsNotNone(ergebnis) self.assertTrue(str(ergebnis).endswith("stil.qml")) def test_nicht_vorhandener_stilpfad_liefert_none(self): """Prüft, dass ein nicht vorhandener Stilpfad None liefert, statt einen ungültigen Pfad zurückzugeben.""" schreiber, _pm = _make_schreiber() self.assertIsNone(schreiber._resolve_style_path("C:/nicht/vorhanden/stil.qml")) class SchreibeDatenTests(unittest.TestCase): def test_ohne_speicherort_wirft_valueerror(self): """Prüft, dass schreibe_Daten ohne Speicherort einen ValueError wirft.""" schreiber, _pm = _make_schreiber(gpkg_path="") with self.assertRaises(ValueError): schreiber.schreibe_Daten(daten_dict={"daten": {}}, processed_results=[], speicherort="") def test_eintrag_ohne_gueltigen_layer_wird_uebersprungen(self): """Prüft, dass ein Eintrag ohne gültigen Layer übersprungen wird und ein Fehlerergebnis meldet.""" schreiber, pruefmanager = _make_schreiber() daten_dict = {"daten": {"1": {"layer": None}}} ergebnisse = schreiber.schreibe_Daten(daten_dict=daten_dict, processed_results=[], speicherort="C:/tmp/ziel.gpkg") self.assertEqual(ergebnisse, []) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("save_exception", aktionen) def test_erfolgreicher_eintrag_liefert_layer_path_und_thema(self): """Prüft, dass ein gültiger Layer einen Layer-Path mit GPKG-Pfad und Layername liefert.""" schreiber, _pm = _make_schreiber(gpkg_path="C:/tmp/ziel.gpkg") daten_dict = {"daten": {"1": {"layer": _FakeLayer(valid=True)}}} processed_results = [ pruef_ergebnis(ok=True, meldung="ok", aktion="datenabruf", kontext={"ident": "1", "thema": "Testthema"}) ] ergebnisse = schreiber.schreibe_Daten( daten_dict=daten_dict, processed_results=processed_results, speicherort="C:/tmp/ziel.gpkg" ) self.assertEqual(len(ergebnisse), 1) eintrag = ergebnisse[0] self.assertEqual(eintrag["thema"], "Testthema") self.assertEqual(eintrag["ident"], "1") self.assertEqual(eintrag["layer_path"], "C:/tmp/ziel.gpkg|layername=Testthema") def test_ohne_thema_wird_ident_als_layername_verwendet(self): """Prüft, dass ohne passendes Thema in processed_results der ident als Layername verwendet wird.""" schreiber, _pm = _make_schreiber(gpkg_path="C:/tmp/ziel.gpkg") daten_dict = {"daten": {"42": {"layer": _FakeLayer(valid=True)}}} ergebnisse = schreiber.schreibe_Daten(daten_dict=daten_dict, processed_results=[], speicherort="C:/tmp/ziel.gpkg") self.assertEqual(ergebnisse[0]["thema"], "42") class LadeLayerTests(unittest.TestCase): def test_ohne_layer_path_wird_uebersprungen(self): """Prüft, dass ein Eintrag ohne layer_path übersprungen wird, ohne einen Fehler zu melden.""" schreiber, pruefmanager = _make_schreiber() schreiber.lade_Layer([{"thema": "ohne_pfad"}]) self.assertEqual(pruefmanager.ergebnisse, []) def test_gueltiger_layer_wird_dem_projekt_hinzugefuegt(self): """Prüft, dass ein gültig ladbarer Layer ohne Fehlermeldung verarbeitet wird.""" schreiber, pruefmanager = _make_schreiber() schreiber.lade_Layer([{"layer_path": "C:/tmp/ziel.gpkg|layername=Testthema", "thema": "Testthema"}]) fehler = [e for e in pruefmanager.ergebnisse if not getattr(e, "ok", True)] self.assertEqual(fehler, []) @patch("sn_basis.modules.Datenschreiber.qgiscore.QgsVectorLayer") def test_ungueltiger_layer_meldet_layer_nicht_gefunden(self, mock_layer_cls): """Prüft, dass ein ungültig ladbarer Layer als 'layer_nicht_gefunden' gemeldet wird.""" mock_layer_cls.return_value = _FakeLayer(valid=False) schreiber, pruefmanager = _make_schreiber() schreiber.lade_Layer([{"layer_path": "C:/tmp/ziel.gpkg|layername=Testthema", "thema": "Testthema"}]) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("layer_nicht_gefunden", aktionen) class SchreibeLogTests(unittest.TestCase): def test_ohne_speicherort_wirft_valueerror(self): """Prüft, dass schreibe_log ohne Speicherort einen ValueError wirft.""" schreiber, _pm = _make_schreiber() with self.assertRaises(ValueError): schreiber.schreibe_log(processed_results=[], speicherort="") def test_schreibt_json_datei_mit_ergebnissen_und_gibt_pfad_zurueck(self): """Prüft, dass schreibe_log eine lesbare JSON-Log-Datei erzeugt und deren Pfad zurückgibt.""" schreiber, pruefmanager = _make_schreiber() processed_results = [ pruef_ergebnis(ok=True, meldung="alles ok", aktion="datenabruf", kontext={"ident": "1"}), pruef_ergebnis(ok=False, meldung="Fehler", aktion="url_nicht_erreichbar", kontext={"ident": "2"}), ] with tempfile.TemporaryDirectory() as tmp: log_path = schreiber.schreibe_log(processed_results=processed_results, speicherort=tmp) self.assertTrue(Path(log_path).is_file()) inhalt = json.loads(Path(log_path).read_text(encoding="utf-8")) self.assertEqual(len(inhalt), 2) self.assertEqual(inhalt[0]["meldung"], "alles ok") self.assertEqual(inhalt[1]["kontext"], {"ident": "2"}) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("standarddatei_vorschlagen", aktionen) class StoreStyleInGpkgTests(unittest.TestCase): """Tests für Datenschreiber._store_style_in_gpkg (Issue #70, nachgezogen aus #63).""" def test_legt_tabelle_an_und_speichert_stil_als_default(self): """Prüft, dass _store_style_in_gpkg die layer_styles-Tabelle anlegt und den Stil als Default speichert.""" tmp = tempfile.mkdtemp() try: gpkg_path = str(Path(tmp) / "ziel.gpkg") style_path = Path(tmp) / "stil.qml" style_path.write_text("Test-QML", encoding="utf-8") schreiber, _pm = _make_schreiber(gpkg_path=gpkg_path) schreiber._store_style_in_gpkg("mein_layer", str(style_path)) with sqlite3.connect(gpkg_path) as conn: zeile = conn.execute( "SELECT f_table_name, styleName, styleQML, useAsDefault FROM layer_styles" ).fetchone() conn.close() finally: # _store_style_in_gpkg schließt seine eigene sqlite3-Verbindung nicht (Produktionscode, # nicht Teil von Issue #70) – ohne gc.collect() bleibt die .gpkg-Datei unter Windows # gesperrt und shutil.rmtree schlägt fehl. gc.collect() shutil.rmtree(tmp, ignore_errors=True) self.assertEqual(zeile[0], "mein_layer") self.assertEqual(zeile[1], "stil.qml") self.assertEqual(zeile[2], "Test-QML") self.assertEqual(zeile[3], 1) def test_setzt_bisherigen_default_stil_desselben_layers_zurueck(self): """Prüft, dass _store_style_in_gpkg den useAsDefault-Wert vorhandener Stile desselben Layers zurücksetzt.""" tmp = tempfile.mkdtemp() try: gpkg_path = str(Path(tmp) / "ziel.gpkg") style_path = Path(tmp) / "stil.qml" style_path.write_text("Neuer Stil", encoding="utf-8") schreiber, _pm = _make_schreiber(gpkg_path=gpkg_path) schreiber._store_style_in_gpkg("mein_layer", str(style_path)) schreiber._store_style_in_gpkg("mein_layer", str(style_path)) with sqlite3.connect(gpkg_path) as conn: werte = [ row[0] for row in conn.execute( "SELECT useAsDefault FROM layer_styles WHERE f_table_name = ? ORDER BY id", ("mein_layer",), ).fetchall() ] conn.close() finally: gc.collect() shutil.rmtree(tmp, ignore_errors=True) self.assertEqual(werte, [0, 1]) def test_liest_geometriespalte_ueber_geometrycolumn_aus(self): """Prüft, dass _store_style_in_gpkg die Geometriespalte eines Layers mit geometryColumn() übernimmt.""" class _LayerMitGeometrieSpalte: def geometryColumn(self) -> str: return "geom" tmp = tempfile.mkdtemp() try: gpkg_path = str(Path(tmp) / "ziel.gpkg") style_path = Path(tmp) / "stil.qml" style_path.write_text("", encoding="utf-8") schreiber, _pm = _make_schreiber(gpkg_path=gpkg_path) schreiber._store_style_in_gpkg("mein_layer", str(style_path), layer=_LayerMitGeometrieSpalte()) with sqlite3.connect(gpkg_path) as conn: spalte = conn.execute( "SELECT f_geometry_column FROM layer_styles WHERE f_table_name = ?", ("mein_layer",), ).fetchone()[0] conn.close() finally: gc.collect() shutil.rmtree(tmp, ignore_errors=True) self.assertEqual(spalte, "geom") def test_fehlender_stilpfad_meldet_fehler_ueber_pruefmanager_statt_absturz(self): """Prüft, dass ein nicht lesbarer Stilpfad über den Pruefmanager gemeldet wird statt eine Exception zu werfen.""" with tempfile.TemporaryDirectory() as tmp: gpkg_path = str(Path(tmp) / "ziel.gpkg") schreiber, pruefmanager = _make_schreiber(gpkg_path=gpkg_path) schreiber._store_style_in_gpkg("mein_layer", str(Path(tmp) / "nicht_vorhanden.qml")) aktionen = [getattr(e, "aktion", None) for e in pruefmanager.ergebnisse] self.assertIn("style_gpkg_speichern_fehlgeschlagen", aktionen) if __name__ == "__main__": unittest.main()