diff --git a/src/core/source_manager.py b/src/core/source_manager.py index 8250d83..d7345f9 100644 --- a/src/core/source_manager.py +++ b/src/core/source_manager.py @@ -244,18 +244,21 @@ def search_sources(self, query: str) -> List[LitSource]: for source in self.get_all_sources(): # Titel - if query in source.meta.title.lower(): + # Bugsweep (2026-06-23, Hardening): title/authors/tags defensiv gegen None + # absichern. Ueber den Load-Pfad sind sie zwar nie None (from_dict defaultet), + # aber programmatisch erzeugte LiMeta(title=None) wuerden hier crashen. + if query in (source.meta.title or "").lower(): results.append(source) continue - + # Autoren - for author in source.meta.authors: + for author in (source.meta.authors or []): if query in author.lower(): results.append(source) break else: # Tags - for tag in source.meta.tags: + for tag in (source.meta.tags or []): if query in tag.lower(): results.append(source) break diff --git a/src/formats/base.py b/src/formats/base.py index 5c3c15a..fb69aee 100644 --- a/src/formats/base.py +++ b/src/formats/base.py @@ -158,6 +158,50 @@ def load(cls: Type[T], path: Path) -> T: raise LitFormatError(f"Datei konnte nicht interpretiert werden: {path}: {e}") +def to_optional_year(value: Any) -> Optional[int]: + """Konvertiert Jahresangaben sicher zu int oder None. + + Erweitert to_optional_int um Float-String-Unterstützung ("2023.0" -> 2023), + die bei manuell bearbeiteten oder aus Tabellentools importierten JSON-Dateien + auftreten kann. + + Disambiguierungs-Suffixe ("2023a") liefern None — kein verifizierter Bedarf; + der CAVEAT in AUFGABEN.txt bleibt aktiv bis ein konkreter Einsatz gemeldet wird. + Bekannte "kein Datum"-Marker ("n.d.", "o.J." usw.) liefern ebenfalls None. + """ + if value is None or value == "": + return None + # Direkte int-Konvertierung (native int / float wie 2023.0) + try: + return int(value) + except (TypeError, ValueError): + pass + # Float-String: "2023.0" -> 2023 + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + pass + # Alle anderen Strings (inkl. "n.d.", "o.J.", "2023a") -> None + return None + + +def to_optional_int(value: Any) -> Optional[int]: + """Konvertiert einen Wert sicher zu int oder None. + + Bugsweep (2026-06-23): Integer-Felder (z.B. page, page_end, year) wurden in + from_dict() ungeprueft uebernommen. Eine alte/handeditierte/importierte Datei + mit "page": "10" (String) liess from_dict sauber durchlaufen, crashte aber + spaeter bei Seitenvergleichen ("10" <= 7 -> TypeError). Hier wird robust + konvertiert. + """ + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def generate_id(prefix: str = "") -> str: """Generates a unique timestamp-based ID with an optional prefix.""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") diff --git a/src/formats/limeta.py b/src/formats/limeta.py index e85ab57..bdb33f7 100644 --- a/src/formats/limeta.py +++ b/src/formats/limeta.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import List, Optional -from .base import LitFormat, now_iso +from .base import LitFormat, now_iso, to_optional_int, to_optional_year @dataclass @@ -78,7 +78,7 @@ def from_dict(cls, data: dict) -> "LiMeta": return cls( title=data.get("title") or "Untitled", authors=data.get("authors") or [], - year=data.get("year"), + year=to_optional_year(data.get("year")), doi=data.get("doi"), isbn=data.get("isbn"), publisher=data.get("publisher"), diff --git a/src/formats/liquote.py b/src/formats/liquote.py index ca3e97c..54a1b24 100644 --- a/src/formats/liquote.py +++ b/src/formats/liquote.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import List, Optional -from .base import LitFormat, generate_id, now_iso +from .base import LitFormat, generate_id, now_iso, to_optional_int @dataclass @@ -40,8 +40,8 @@ def from_dict(cls, data: dict) -> "Quote": id=data.get("id", generate_id("q_")), type=data.get("type", "direct"), text=data.get("text", ""), - page=data.get("page"), - page_end=data.get("page_end"), + page=to_optional_int(data.get("page")), + page_end=to_optional_int(data.get("page_end")), comment=data.get("comment"), tags=data.get("tags") or [], used_in=data.get("used_in") or [], diff --git a/src/modules/bibliography/bibtex.py b/src/modules/bibliography/bibtex.py index a9b0ed8..3b71318 100644 --- a/src/modules/bibliography/bibtex.py +++ b/src/modules/bibliography/bibtex.py @@ -9,9 +9,30 @@ from formats import LiMeta +def escape_bibtex(value: str) -> str: + """Escaped LaTeX/BibTeX-Sonderzeichen in Freitextfeldern. + + Bugsweep (2026-06-23): Feldwerte wurden ungeescaped in ``{...}`` eingefügt. + Ein Titel wie ``AT&T Research`` oder ``cost_analysis`` (auch ``10% Anteil``) + erzeugte unkompilierbares BibTeX. Der Backslash wird über einen Sentinel + geschützt, damit die nachfolgende Klammer-Escapierung das eingefügte + ``\\textbackslash{}`` nicht erneut verändert. + """ + if not value: + return value + sentinel = "\x00" + value = value.replace("\\", sentinel) + for ch in ("&", "%", "$", "#", "_", "{", "}"): + value = value.replace(ch, "\\" + ch) + value = value.replace("~", r"\textasciitilde{}") + value = value.replace("^", r"\textasciicircum{}") + value = value.replace(sentinel, r"\textbackslash{}") + return value + + class BibTeXGenerator: """Generiert BibTeX-Einträge""" - + TYPE_MAP = { "article": "article", "book": "book", @@ -25,26 +46,26 @@ class BibTeXGenerator: def generate_entry(self, meta: LiMeta) -> str: """Generiert einen BibTeX-Eintrag""" entry_type = self.TYPE_MAP.get(meta.source_type, "misc") - key = meta.citation_key + key = meta.bibtex_key lines = [f"@{entry_type}{{{key},"] # Titel if meta.title: - lines.append(f' title = {{{meta.title}}},') - + lines.append(f' title = {{{escape_bibtex(meta.title)}}},') + # Autoren if meta.authors: - authors = " and ".join(meta.authors) + authors = " and ".join(escape_bibtex(a) for a in meta.authors) lines.append(f' author = {{{authors}}},') - + # Jahr if meta.year: lines.append(f' year = {{{meta.year}}},') - + # Journal (für Artikel) if meta.journal: - lines.append(f' journal = {{{meta.journal}}},') + lines.append(f' journal = {{{escape_bibtex(meta.journal)}}},') # Volume if meta.volume: @@ -60,7 +81,7 @@ def generate_entry(self, meta: LiMeta) -> str: # Verlag if meta.publisher: - lines.append(f' publisher = {{{meta.publisher}}},') + lines.append(f' publisher = {{{escape_bibtex(meta.publisher)}}},') # DOI if meta.doi: @@ -76,7 +97,7 @@ def generate_entry(self, meta: LiMeta) -> str: # Abstract if meta.abstract: - abstract = meta.abstract.replace("\n", " ") + abstract = escape_bibtex(meta.abstract.replace("\n", " ")) lines.append(f' abstract = {{{abstract}}},') # Schließende Klammer diff --git a/src/modules/bibliography/bibtex_generator.py b/src/modules/bibliography/bibtex_generator.py index 8c2c008..915d908 100644 --- a/src/modules/bibliography/bibtex_generator.py +++ b/src/modules/bibliography/bibtex_generator.py @@ -7,6 +7,7 @@ from datetime import datetime from formats import LiMeta +from modules.bibliography.bibtex import escape_bibtex class BibTeXGenerator: @@ -34,66 +35,66 @@ def add_entry(self, meta: LiMeta) -> str: def _generate_entry(self, meta: LiMeta) -> str: """Generiert einen einzelnen BibTeX-Eintrag""" bib_type = self.TYPE_MAP.get(meta.source_type, "misc") - key = meta.citation_key + key = meta.bibtex_key lines = [f"@{bib_type}{{{key},"] # Titel if meta.title: - lines.append(f" title = {{{meta.title}}},") - + lines.append(f" title = {{{escape_bibtex(meta.title)}}},") + # Autoren if meta.authors: - authors_str = " and ".join(meta.authors) + authors_str = " and ".join(escape_bibtex(a) for a in meta.authors) lines.append(f" author = {{{authors_str}}},") - + # Jahr if meta.year: lines.append(f" year = {{{meta.year}}},") - + # Journal/Booktitle if meta.journal: if bib_type == "article": - lines.append(f" journal = {{{meta.journal}}},") + lines.append(f" journal = {{{escape_bibtex(meta.journal)}}},") else: - lines.append(f" booktitle = {{{meta.journal}}},") - + lines.append(f" booktitle = {{{escape_bibtex(meta.journal)}}},") + # Volume if meta.volume: lines.append(f" volume = {{{meta.volume}}},") - + # Number/Issue if meta.issue: lines.append(f" number = {{{meta.issue}}},") - + # Pages if meta.pages: lines.append(f" pages = {{{meta.pages}}},") - + # Publisher if meta.publisher: - lines.append(f" publisher = {{{meta.publisher}}},") - - # DOI + lines.append(f" publisher = {{{escape_bibtex(meta.publisher)}}},") + + # DOI (verbatim — Escaping wuerde \url/hyperref brechen) if meta.doi: lines.append(f" doi = {{{meta.doi}}},") - + # ISBN if meta.isbn: lines.append(f" isbn = {{{meta.isbn}}},") - - # URL + + # URL (verbatim — Escaping wuerde \url/hyperref brechen) if meta.url: lines.append(f" url = {{{meta.url}}},") - + # Abstract if meta.abstract: - abstract = meta.abstract.replace("\n", " ") + abstract = escape_bibtex(meta.abstract.replace("\n", " ")) lines.append(f" abstract = {{{abstract}}},") - + # Keywords/Tags if meta.tags: - keywords = ", ".join(meta.tags) + keywords = escape_bibtex(", ".join(meta.tags)) lines.append(f" keywords = {{{keywords}}},") lines.append("}") diff --git a/src/modules/sync/__init__.py b/src/modules/sync/__init__.py index db49040..afaebb2 100644 --- a/src/modules/sync/__init__.py +++ b/src/modules/sync/__init__.py @@ -165,7 +165,7 @@ def create_backup(self, name: str = None) -> Optional[Path]: ) return backup_path except Exception as e: - print(f"Backup-Fehler: {e}") + logging.error(f"Backup-Fehler: {e}") return None def list_backups(self) -> List[Path]: @@ -184,27 +184,65 @@ def list_backups(self) -> List[Path]: return backups def restore_backup(self, backup_path: Path) -> bool: - """Stellt Backup wieder her""" + """Stellt Backup wieder her. + + Bugsweep (2026-06-23): Vorher wurde das Live-Projekt ZUERST umbenannt und + dann das Backup per copytree hineinkopiert. Schlug copytree fehl (Disk voll, + OneDrive-Lock, Permissions), war das Live-Projekt weg und es gab kein Rollback. + Jetzt: erst in einen Staging-Ordner kopieren (der fehleranfaellige Schritt), + und nur bei Erfolg in zwei nahezu atomaren Renames austauschen. Bei einem + Fehler bleibt das Live-Projekt unangetastet bzw. wird zurueckgerollt. + """ if not backup_path.exists(): return False - + + parent = self.project_path.parent + name = self.project_path.name + + # Liegengebliebene Sicherungen frueherer, fehlgeschlagener Restores sind unter + # Umstaenden die EINZIGE Kopie des Projekts: nie loeschen, nie ueberschreiben. + leftovers = sorted(parent.glob(f"{name}_before_restore*")) + if not self.project_path.exists() and leftovers: + logging.error( + "Restore abgebrochen: Projektordner fehlt, aber es gibt Reste eines " + f"frueheren Restores ({', '.join(p.name for p in leftovers)}). " + "Bitte zuerst manuell wiederherstellen." + ) + return False + + staging = parent / f"{name}_restore_staging" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + old_tmp = parent / f"{name}_before_restore_{stamp}" + counter = 1 + while old_tmp.exists(): + counter += 1 + old_tmp = parent / f"{name}_before_restore_{stamp}-{counter}" + + # Schritt 1: Backup in Staging kopieren (Live-Projekt bleibt unberuehrt). + try: + if staging.exists(): + shutil.rmtree(staging) + shutil.copytree(backup_path, staging) + except (OSError, shutil.Error) as e: + logging.error(f"Restore-Fehler (Staging-Kopie): {e}") + shutil.rmtree(staging, ignore_errors=True) + return False + + # Schritt 2: Live-Projekt gegen Staging austauschen (zwei Renames). try: - # Aktuelles Projekt sichern - temp_backup = self.project_path.parent / f"{self.project_path.name}_before_restore" - if temp_backup.exists(): - shutil.rmtree(temp_backup) - - self.project_path.rename(temp_backup) - - # Backup wiederherstellen - shutil.copytree(backup_path, self.project_path) - - # Temporäres Backup löschen - shutil.rmtree(temp_backup) - + self.project_path.rename(old_tmp) + staging.rename(self.project_path) + shutil.rmtree(old_tmp, ignore_errors=True) return True - except Exception as e: - print(f"Restore-Fehler: {e}") + except OSError as e: + logging.error(f"Restore-Fehler (Austausch): {e}") + # Rollback: falls das Live-Projekt bereits weg ist, zuruecksetzen. + if not self.project_path.exists() and old_tmp.exists(): + try: + old_tmp.rename(self.project_path) + except OSError as rollback_err: + logging.error(f"Restore-Rollback fehlgeschlagen: {rollback_err}") + shutil.rmtree(staging, ignore_errors=True) return False def delete_backup(self, backup_path: Path) -> bool: diff --git a/start.bat b/start.bat index 882a231..b09c089 100644 --- a/start.bat +++ b/start.bat @@ -17,7 +17,7 @@ REM Wechsle zum Projektverzeichnis cd /d "%~dp0" REM Prüfe ob Abhängigkeiten installiert sind -python -c "import PyQt6" >nul 2>&1 +python -c "import PySide6" >nul 2>&1 if errorlevel 1 ( echo [INFO] Installiere Abhängigkeiten... pip install -r requirements.txt diff --git a/tests/source_platform_smoke.py b/tests/source_platform_smoke.py index bb66af1..7dd3427 100644 --- a/tests/source_platform_smoke.py +++ b/tests/source_platform_smoke.py @@ -128,7 +128,7 @@ def run_source_platform_smoke() -> dict: BibTeXGenerator().save_bibliography([source.meta for source in sources], bib_path) bib_file = bib_path.with_suffix(".bib") bib_text = bib_file.read_text(encoding="utf-8") - assert "@article{Müller2026," in bib_text + assert "@article{mueller_2026_aerztliche," in bib_text assert "title = {Ärztliche Übersicht zur Mobilität}" in bib_text assert "author = {Müller, Jörg}" in bib_text diff --git a/tests/test_bibtex_export.py b/tests/test_bibtex_export.py index 53ed377..3f2368b 100644 --- a/tests/test_bibtex_export.py +++ b/tests/test_bibtex_export.py @@ -34,10 +34,25 @@ def test_save_bibliography_creates_parent_directory_and_bib_suffix(self): self.assertTrue(expected_path.exists()) content = expected_path.read_text(encoding="utf-8") - self.assertIn(f"@article{{{meta.citation_key},", content) + self.assertIn(f"@article{{{meta.bibtex_key},", content) self.assertIn("title = {Test Article}", content) self.assertIn("author = {Doe, Jane}", content) + def test_bibtex_entry_key_uses_sanitized_bibtex_key(self): + from formats import LiMeta + from modules.bibliography.bibtex import BibTeXGenerator + + meta = LiMeta( + title="Research Platforms", + authors=["AT&T Research"], + year=2024, + ) + + entry = BibTeXGenerator().generate_entry(meta) + + self.assertTrue(entry.startswith("@article{attresearch_2024_research,")) + self.assertNotIn("@article{AT&T2024,", entry) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_bug_regressions_20260623.py b/tests/test_bug_regressions_20260623.py new file mode 100644 index 0000000..92153dd --- /dev/null +++ b/tests/test_bug_regressions_20260623.py @@ -0,0 +1,283 @@ +"""Regressionstests Bugsweep 2026-06-23 (Desktop, /bugsweep-Loop Run 14/15). + +Vier echte Bugs, die der vorherige Sweep (Bugsweep 27) nicht erfasst hatte: + +1. bibtex.py: BibTeX-Feldwerte ohne Escaping von & % _ # { } -> unkompilierbares BibTeX. +2. liquote.py: page_range lieferte "None-10" wenn page None aber page_end gesetzt; + ausserdem int-Felder ohne Konvertierung -> "10" <= 7 TypeError in get_by_page. +3. sync/__init__.py: restore_backup ohne Rollback -> Live-Projekt-Verlust bei copytree-Fehler. +4. source_manager.py (Hardening): search_sources ohne None-Guard fuer title/authors/tags. +""" +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +# --- Bug 1: BibTeX-Escaping ------------------------------------------------- + +def test_bibtex_escapes_special_chars(): + from formats import LiMeta + from modules.bibliography.bibtex import BibTeXGenerator + + meta = LiMeta( + title="AT&T cost_analysis 10% #1", + authors=["Müller, A&B"], + year=2024, + publisher="O'Reilly & Co_Ltd", + ) + entry = BibTeXGenerator().generate_entry(meta) + # Vor dem Fix standen &, _, %, # roh im Output -> unkompilierbar. + assert r"\&" in entry + assert r"\_" in entry + assert r"\%" in entry + assert r"\#" in entry + # Kein roh stehendes ungeescaptes & mehr im Titel-/Autoren-/Verlagsfeld: + assert "AT&T" not in entry # muss "AT\&T" sein + assert "cost_analysis" not in entry # muss "cost\_analysis" sein + + +def test_bibtex_entry_key_uses_existing_sanitized_key(): + from formats import LiMeta + from modules.bibliography.bibtex import BibTeXGenerator + + meta = LiMeta( + title="Research Platforms", + authors=["O'Brien Labs"], + year=2024, + ) + entry = BibTeXGenerator().generate_entry(meta) + + assert entry.startswith("@article{obrienlabs_2024_research,") + assert "@article{O'Brien2024," not in entry + + +def test_legacy_bibtex_file_generator_uses_sanitized_key(tmp_path): + from formats import LiMeta + from modules.bibliography.bibtex_generator import BibTeXGenerator + + meta = LiMeta( + title="Research Platforms", + authors=["AT&T Research"], + year=2024, + ) + output = tmp_path / "refs.bib" + + BibTeXGenerator().generate_file([meta], output) + + content = output.read_text(encoding="utf-8") + assert "@article{attresearch_2024_research," in content + assert "@article{AT&T2024," not in content + + +def test_legacy_bibtex_generator_escapes_field_values(tmp_path): + """Bugsweep Run 74: bibtex_generator.py escapte Feldwerte nicht (nur bibtex.py tat es). + + Sonderzeichen wie & % _ # in Titeln/Autoren/Verlagen erzeugten in bibtex_generator.py + unkompilierbares BibTeX. Fix: escape_bibtex aus bibtex.py importiert und auf alle + Freitextfelder (titel, autoren, journal, publisher, abstract, keywords) angewendet. + URL/DOI/ISBN bleiben verbatim (Escaping wuerde hyperref-Verlinkung brechen). + """ + from formats import LiMeta + from modules.bibliography.bibtex_generator import BibTeXGenerator + + meta = LiMeta( + title="AT&T cost_analysis 10% #1", + authors=["Müller, A&B"], + year=2024, + publisher="O'Reilly & Co_Ltd", + abstract="Enthält & Sonderzeichen_hier.", + tags=["tag_one", "tag&two"], + url="https://example.org/?a=1&b=2", + doi="10.1000/abc_def", + ) + output = tmp_path / "refs.bib" + BibTeXGenerator().generate_file([meta], output) + content = output.read_text(encoding="utf-8") + + # Freitextfelder muessen escaped sein. + assert r"\&" in content + assert r"\_" in content + assert r"\%" in content + assert r"\#" in content + assert "AT&T" not in content # Titel: muss "AT\&T" sein + assert "cost_analysis" not in content # Titel: muss "cost\_analysis" sein + assert "A&B" not in content # Autor + assert "O'Reilly & Co_Ltd" not in content # Publisher: & und _ escaped + + # URL und DOI bleiben verbatim. + assert "https://example.org/?a=1&b=2" in content + assert "10.1000/abc_def" in content + + +def test_bibtex_url_and_doi_not_escaped(): + from formats import LiMeta + from modules.bibliography.bibtex import BibTeXGenerator + + meta = LiMeta( + title="Test", + authors=["Doe, J"], + year=2024, + url="https://example.org/path?a=1&b=2#frag", + doi="10.1000/abc_def", + ) + entry = BibTeXGenerator().generate_entry(meta) + # url/doi muessen verbatim bleiben (Escapen wuerde \url/hyperref brechen). + assert "https://example.org/path?a=1&b=2#frag" in entry + assert "10.1000/abc_def" in entry + + +def test_bibtex_backslash_roundtrip(): + from modules.bibliography.bibtex import escape_bibtex + + # Backslash darf nicht durch die nachfolgende Klammer-Escapierung zerstoert werden. + assert escape_bibtex(r"a\b") == r"a\textbackslash{}b" + assert escape_bibtex("x{y}z") == r"x\{y\}z" + + +# --- Bug 2: liquote page_range + int-Konvertierung -------------------------- + +def test_quote_page_range_no_none_string(): + from formats.liquote import Quote + + q = Quote(id="q1", text="t", created_at="2024", page=None, page_end=10) + # Vor dem Fix: "None-10". Aktuelles master-Verhalten (test_formats.py): nur die gesetzte Seite. + assert q.page_range == "10" + + q2 = Quote(id="q2", text="t", created_at="2024", page=5, page_end=10) + assert q2.page_range == "5-10" + + q3 = Quote(id="q3", text="t", created_at="2024", page=7, page_end=None) + assert q3.page_range == "7" + + +def test_liquote_int_coercion_from_dict(): + from formats.liquote import LiQuote + + # Alte/handeditierte Datei mit String-Seitenzahlen. + data = {"quotes": [{"id": "q1", "text": "zitat", "page": "10", "page_end": "12"}]} + lq = LiQuote.from_dict(data) + assert lq.quotes[0].page == 10 + assert lq.quotes[0].page_end == 12 + # Vor dem Fix crashte dieser Vergleich mit TypeError ("10" <= 11). + hits = lq.get_by_page(11) + assert hits and hits[0].id == "q1" + + +def test_limeta_year_coercion_from_dict(): + from formats import LiMeta + + meta = LiMeta.from_dict({"title": "T", "year": "2023"}) + assert meta.year == 2023 + meta_bad = LiMeta.from_dict({"title": "T", "year": "nicht-zahl"}) + assert meta_bad.year is None + + +# --- Bug 3: sync restore_backup Rollback ------------------------------------ + +def test_restore_backup_keeps_live_project_on_copytree_failure(tmp_path, monkeypatch): + from modules.sync import BackupManager + + project = tmp_path / "projekt" + project.mkdir() + (project / "wichtig.txt").write_text("LIVE-DATEN", encoding="utf-8") + + backup = tmp_path / "backup_quelle" + backup.mkdir() + (backup / "wichtig.txt").write_text("BACKUP-DATEN", encoding="utf-8") + + mgr = BackupManager(project) + + # copytree (Staging-Schritt) schlaegt fehl. + def boom(*args, **kwargs): + raise OSError("Disk voll") + + monkeypatch.setattr(shutil, "copytree", boom) + + ok = mgr.restore_backup(backup) + assert ok is False + # Kritisch: Live-Projekt muss unangetastet und mit Originalinhalt vorhanden sein. + assert project.exists() + assert (project / "wichtig.txt").read_text(encoding="utf-8") == "LIVE-DATEN" + + +def test_restore_backup_success(tmp_path): + from modules.sync import BackupManager + + project = tmp_path / "projekt" + project.mkdir() + (project / "wichtig.txt").write_text("LIVE-DATEN", encoding="utf-8") + + backup = tmp_path / "backup_quelle" + backup.mkdir() + (backup / "wichtig.txt").write_text("BACKUP-DATEN", encoding="utf-8") + + mgr = BackupManager(project) + ok = mgr.restore_backup(backup) + assert ok is True + assert (project / "wichtig.txt").read_text(encoding="utf-8") == "BACKUP-DATEN" + # Keine Staging-/Temp-Reste. + assert not (tmp_path / "projekt_restore_staging").exists() + assert not list(tmp_path.glob("projekt_before_restore*")) + + +# --- Bug 4: source_manager None-Hardening ----------------------------------- + +def test_search_sources_handles_none_title(tmp_path): + from core.source_manager import LitSource, SourceManager + from formats import LiMeta + + mgr = SourceManager(tmp_path, "quellen") + + # Programmatisch erzeugte Quelle mit None-Title/-Authors/-Tags. + meta = LiMeta(title="Brauchbar") + meta.title = None + meta.authors = None + meta.tags = None + src = LitSource(path=tmp_path / "s1", meta=meta) + + # get_all_sources direkt ueberschreiben -> Logik mit None-Feldern pruefen. + mgr.get_all_sources = lambda: [src] # type: ignore + # Darf nicht crashen (vor dem Fix: AttributeError bei None.lower()): + assert mgr.search_sources("xyz") == [] + + +def test_restore_backup_never_deletes_leftover_before_restore(tmp_path): + """Reste eines frueheren Restores (evtl. einzige Kopie) bleiben erhalten.""" + from modules.sync import BackupManager + + project = tmp_path / "projekt" + project.mkdir() + (project / "live.txt").write_text("live", encoding="utf-8") + backup = tmp_path / "backup" + backup.mkdir() + (backup / "alt.txt").write_text("backup", encoding="utf-8") + leftover = tmp_path / "projekt_before_restore" + leftover.mkdir() + (leftover / "einzige_kopie.txt").write_text("wichtig", encoding="utf-8") + + mgr = BackupManager(project) + assert mgr.restore_backup(backup) is True + + assert (leftover / "einzige_kopie.txt").read_text(encoding="utf-8") == "wichtig" + assert (project / "alt.txt").exists() + assert not list(tmp_path.glob("projekt_before_restore_*")) + + +def test_restore_backup_aborts_when_project_missing_and_leftover_exists(tmp_path): + from modules.sync import BackupManager + + project = tmp_path / "projekt" # fehlt: Live-Projekt liegt nur noch im Rest + backup = tmp_path / "backup" + backup.mkdir() + (backup / "alt.txt").write_text("backup", encoding="utf-8") + leftover = tmp_path / "projekt_before_restore_20260101-000000" + leftover.mkdir() + (leftover / "einzige_kopie.txt").write_text("wichtig", encoding="utf-8") + + assert BackupManager(project).restore_backup(backup) is False + + assert (leftover / "einzige_kopie.txt").exists() + assert not project.exists() + diff --git a/tests/test_year_coercion.py b/tests/test_year_coercion.py new file mode 100644 index 0000000..ad955d8 --- /dev/null +++ b/tests/test_year_coercion.py @@ -0,0 +1,150 @@ +"""Regressionstests year-Coercion (Bugsweep 2026-06-23, CAVEAT limeta.py). + +Verifiziert das Verhalten von to_optional_year (base.py) und die +Integration ueber LiMeta.from_dict fuer alle relevanten Edge-Cases: + +- None, leerer String -> None +- "n.d.", "o.J.", "s.d." -> None (kein-Datum-Marker) +- "2023", 2023, 2023.0 -> 2023 (normale Faelle) +- "2023.0" (Float-String) -> 2023 (der fixte Gap: frueher None) +- "2023a" (Disambiguierung)-> None (defensive Konvertierung; CAVEAT bleibt offen) +- "invalid", "abc" -> None + +Hintergrund: to_optional_int konnte keine Float-Strings konvertieren +("2023.0" -> ValueError -> None). to_optional_year loest das mit einem +int(float(s))-Fallback ohne die gemeinsam genutzte to_optional_int fuer +page/page_end-Felder zu aendern. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +# --------------------------------------------------------------------------- +# to_optional_year direkt +# --------------------------------------------------------------------------- + +def test_year_none_returns_none(): + from formats.base import to_optional_year + assert to_optional_year(None) is None + + +def test_year_empty_string_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("") is None + + +def test_year_string_int_converts(): + from formats.base import to_optional_year + assert to_optional_year("2023") == 2023 + + +def test_year_native_int_converts(): + from formats.base import to_optional_year + assert to_optional_year(2023) == 2023 + + +def test_year_native_float_converts(): + from formats.base import to_optional_year + assert to_optional_year(2023.0) == 2023 + + +def test_year_float_string_converts(): + """Fixte Luecke: 'to_optional_int' konnte '2023.0' nicht -> None.""" + from formats.base import to_optional_year + assert to_optional_year("2023.0") == 2023 + + +def test_year_nd_marker_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("n.d.") is None + + +def test_year_oj_marker_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("o.J.") is None + + +def test_year_sd_marker_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("s.d.") is None + + +def test_year_generic_string_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("invalid") is None + + +def test_year_abc_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("abc") is None + + +def test_year_disambiguation_suffix_returns_none(): + """Defensiv: '2023a' -> None (kein verifizierter Bedarf; CAVEAT bleibt offen). + + Solange kein Produzent/Konsument fuer Disambiguierungs-Jahre identifiziert + wurde, bleibt None die sichere Konvention. + """ + from formats.base import to_optional_year + assert to_optional_year("2023a") is None + + +def test_year_mixed_alpha_returns_none(): + from formats.base import to_optional_year + assert to_optional_year("nicht-zahl") is None + + +# --------------------------------------------------------------------------- +# Integration: LiMeta.from_dict +# --------------------------------------------------------------------------- + +def test_limeta_from_dict_year_string(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": "2023"}) + assert meta.year == 2023 + + +def test_limeta_from_dict_year_float_string(): + """Regression fuer den Float-String-Gap.""" + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": "2023.0"}) + assert meta.year == 2023 + + +def test_limeta_from_dict_year_nd(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": "n.d."}) + assert meta.year is None + + +def test_limeta_from_dict_year_none(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": None}) + assert meta.year is None + + +def test_limeta_from_dict_year_empty(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": ""}) + assert meta.year is None + + +def test_limeta_from_dict_year_disambiguation_stays_none(): + """Defensiv: '2023a' -> None; bestehender CAVEAT-Eintrag bleibt offen.""" + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": "2023a"}) + assert meta.year is None + + +def test_limeta_from_dict_year_native_int(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": 2023}) + assert meta.year == 2023 + + +def test_limeta_from_dict_year_invalid_string(): + from formats import LiMeta + meta = LiMeta.from_dict({"title": "T", "year": "nicht-zahl"}) + assert meta.year is None