457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""Unit-Tests für die WissensdatenbankSource.
|
||
|
||
Testet den Adapter für bahn/wissensdatenbank/output/ – Read-Only-Referenzen
|
||
im bahn/knowledge/ YAML-Index ohne Modifikation der Originaldateien.
|
||
|
||
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from monorepo.knowledge.sources.base import ExtractionResult, SourceConfig
|
||
from monorepo.knowledge.sources.wissensdatenbank import (
|
||
WissensdatenbankSource,
|
||
_compute_file_hash,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def wdb_dir(tmp_path: Path) -> Path:
|
||
"""Erstellt ein temporäres Wissensdatenbank-Output-Verzeichnis mit Testdaten."""
|
||
output = tmp_path / "wissensdatenbank" / "output"
|
||
output.mkdir(parents=True)
|
||
|
||
# Einige Markdown-Dateien erstellen
|
||
(output / "inb-2026-kapitel-1.md").write_text(
|
||
"# INB 2026 Kapitel 1\n\nInhalt des ersten Kapitels.",
|
||
encoding="utf-8",
|
||
)
|
||
(output / "inb-2026-kapitel-2.md").write_text(
|
||
"# INB 2026 Kapitel 2\n\nInhalt des zweiten Kapitels.",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# Unterverzeichnis mit weiterer Datei
|
||
sub = output / "themen"
|
||
sub.mkdir()
|
||
(sub / "signaltechnik.md").write_text(
|
||
"# Signaltechnik\n\nBeschreibung der Signaltechnik.",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# Eine .txt-Datei
|
||
(output / "notizen.txt").write_text(
|
||
"Allgemeine Notizen zur Wissensdatenbank.",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
return output
|
||
|
||
|
||
@pytest.fixture
|
||
def source_config(wdb_dir: Path) -> SourceConfig:
|
||
"""Erstellt eine SourceConfig für die Wissensdatenbank."""
|
||
return SourceConfig(
|
||
type="wissensdatenbank",
|
||
name="Wissensdatenbank",
|
||
params={"output_path": str(wdb_dir)},
|
||
target_folder="references",
|
||
sync_frequency="manual",
|
||
enabled=True,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def source() -> WissensdatenbankSource:
|
||
"""Erstellt eine WissensdatenbankSource-Instanz."""
|
||
return WissensdatenbankSource()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Grundlegende Extraktion (Req 13.1)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBasicExtraction:
|
||
"""Tests für die grundlegende Extraktion von Wissensdatenbank-Artefakten."""
|
||
|
||
def test_extracts_all_markdown_files(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Alle .md- und .txt-Dateien im Output-Verzeichnis werden extrahiert."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
assert isinstance(result, ExtractionResult)
|
||
assert len(result.artifacts) == 4 # 2 .md + 1 subdir .md + 1 .txt
|
||
assert result.skipped == 0
|
||
assert len(result.errors) == 0
|
||
|
||
def test_extracts_recursively(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Dateien in Unterverzeichnissen werden ebenfalls extrahiert."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
file_paths = [str(a.file_path) for a in result.artifacts]
|
||
# signaltechnik.md aus themen/ sollte enthalten sein
|
||
assert any("signaltechnik" in p for p in file_paths)
|
||
|
||
def test_ignores_non_supported_extensions(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Dateien mit nicht-unterstützten Endungen werden ignoriert."""
|
||
(wdb_dir / "bild.png").write_bytes(b"\x89PNG...")
|
||
(wdb_dir / "dokument.pdf").write_bytes(b"%PDF-1.4")
|
||
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
# Nur .md und .txt werden verarbeitet
|
||
assert len(result.artifacts) == 4
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Read-Only-Referenz-Metadaten (Req 13.3)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestReadOnlyMetadata:
|
||
"""Tests für korrekte source-Metadaten der Wissensdatenbank-Artefakte."""
|
||
|
||
def test_source_type_is_wissensdatenbank(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artefakte haben source.type == 'wissensdatenbank'."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert artifact.metadata.source["type"] == "wissensdatenbank"
|
||
|
||
def test_source_path_references_original(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Artefakte enthalten den Originalpfad in source.path."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert "path" in artifact.metadata.source
|
||
# Der Pfad sollte auf eine existierende Datei zeigen
|
||
original_path = Path(artifact.metadata.source["path"])
|
||
assert original_path.exists()
|
||
|
||
def test_source_context_is_bahn(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artefakte haben source_context == 'bahn' (übergebener Kontext)."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert artifact.metadata.source_context == "bahn"
|
||
|
||
def test_artifact_type_is_reference(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artefakte sind vom Typ 'reference'."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert artifact.metadata.type == "reference"
|
||
|
||
def test_category_is_references(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artefakte haben category == 'references'."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert artifact.metadata.category == "references"
|
||
|
||
def test_content_hash_is_set(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artefakte haben einen nicht-leeren content_hash."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
assert artifact.metadata.content_hash.startswith("sha256:")
|
||
|
||
def test_title_derived_from_filename(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Titel wird aus dem Dateinamen abgeleitet."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
titles = [a.metadata.title for a in result.artifacts]
|
||
assert "Inb 2026 Kapitel 1" in titles
|
||
assert "Signaltechnik" in titles
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Originaldateien unverändert (Req 13.2, 13.4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestReadOnlyBehavior:
|
||
"""Tests, dass die Originaldateien nicht modifiziert werden."""
|
||
|
||
def test_original_files_unchanged(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Originaldateien werden nicht verändert – gleicher Hash vor/nach Extraktion."""
|
||
# Hashes vor Extraktion berechnen
|
||
files = list(wdb_dir.rglob("*"))
|
||
hashes_before = {
|
||
str(f): _compute_file_hash(f) for f in files if f.is_file()
|
||
}
|
||
|
||
# Extraktion ausführen
|
||
source.extract(source_config, "bahn")
|
||
|
||
# Hashes nach Extraktion prüfen
|
||
hashes_after = {
|
||
str(f): _compute_file_hash(f) for f in files if f.is_file()
|
||
}
|
||
assert hashes_before == hashes_after
|
||
|
||
def test_no_new_files_created_in_source_dir(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Keine neuen Dateien werden im Quellverzeichnis erstellt."""
|
||
files_before = set(str(f) for f in wdb_dir.rglob("*") if f.is_file())
|
||
|
||
source.extract(source_config, "bahn")
|
||
|
||
files_after = set(str(f) for f in wdb_dir.rglob("*") if f.is_file())
|
||
assert files_before == files_after
|
||
|
||
def test_artifact_file_path_points_to_original(
|
||
self, source: WissensdatenbankSource, source_config: SourceConfig
|
||
) -> None:
|
||
"""Artifact.file_path zeigt auf die Originaldatei (Read-Only-Referenz)."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
for artifact in result.artifacts:
|
||
# file_path zeigt auf die originale Datei
|
||
assert artifact.file_path.exists()
|
||
assert artifact.file_path.suffix.lower() in {".md", ".txt"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Inkrementelle Verarbeitung (Req 13.2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestIncrementalProcessing:
|
||
"""Tests für inkrementelle Verarbeitung via Content-Hash."""
|
||
|
||
def test_supports_incremental(
|
||
self, source: WissensdatenbankSource
|
||
) -> None:
|
||
"""WissensdatenbankSource unterstützt inkrementelle Verarbeitung."""
|
||
assert source.supports_incremental() is True
|
||
|
||
def test_skips_unchanged_files(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Dateien mit bekanntem Hash werden übersprungen."""
|
||
# Erste Extraktion – alles neu
|
||
result1 = source.extract(source_config, "bahn")
|
||
assert result1.skipped == 0
|
||
assert len(result1.artifacts) == 4
|
||
|
||
# Hashes der extrahierten Dateien merken
|
||
known = {}
|
||
for f in wdb_dir.rglob("*"):
|
||
if f.is_file() and f.suffix.lower() in {".md", ".txt"}:
|
||
known[str(f)] = _compute_file_hash(f)
|
||
|
||
source.set_known_hashes(known)
|
||
|
||
# Zweite Extraktion – alles unverändert → alles geskippt
|
||
result2 = source.extract(source_config, "bahn")
|
||
assert result2.skipped == 4
|
||
assert len(result2.artifacts) == 0
|
||
|
||
def test_processes_changed_files(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Geänderte Dateien werden erneut verarbeitet."""
|
||
# Erste Extraktion
|
||
source.extract(source_config, "bahn")
|
||
|
||
# Hashes merken
|
||
known = {}
|
||
for f in wdb_dir.rglob("*"):
|
||
if f.is_file() and f.suffix.lower() in {".md", ".txt"}:
|
||
known[str(f)] = _compute_file_hash(f)
|
||
|
||
source.set_known_hashes(known)
|
||
|
||
# Eine Datei ändern
|
||
changed_file = wdb_dir / "inb-2026-kapitel-1.md"
|
||
changed_file.write_text(
|
||
"# INB 2026 Kapitel 1 (aktualisiert)\n\nNeuer Inhalt.",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# Zweite Extraktion – nur die geänderte Datei wird verarbeitet
|
||
result = source.extract(source_config, "bahn")
|
||
assert len(result.artifacts) == 1
|
||
assert result.skipped == 3
|
||
assert "inb-2026-kapitel-1" in str(result.artifacts[0].file_path)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Fehlerbehandlung
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestErrorHandling:
|
||
"""Tests für die Fehlerbehandlung."""
|
||
|
||
def test_missing_output_path_param(
|
||
self, source: WissensdatenbankSource
|
||
) -> None:
|
||
"""Fehlende output_path-Konfiguration erzeugt einen Fehler."""
|
||
config = SourceConfig(
|
||
type="wissensdatenbank",
|
||
name="Test",
|
||
params={},
|
||
)
|
||
result = source.extract(config, "bahn")
|
||
|
||
assert len(result.errors) == 1
|
||
assert "output_path" in result.errors[0].message
|
||
|
||
def test_nonexistent_directory(
|
||
self, source: WissensdatenbankSource, tmp_path: Path
|
||
) -> None:
|
||
"""Nicht-existierendes Verzeichnis erzeugt einen Fehler."""
|
||
config = SourceConfig(
|
||
type="wissensdatenbank",
|
||
name="Test",
|
||
params={"output_path": str(tmp_path / "nonexistent")},
|
||
)
|
||
result = source.extract(config, "bahn")
|
||
|
||
assert len(result.errors) == 1
|
||
assert result.errors[0].error_type == "connection"
|
||
assert result.errors[0].retry is True
|
||
|
||
def test_path_is_file_not_directory(
|
||
self, source: WissensdatenbankSource, tmp_path: Path
|
||
) -> None:
|
||
"""Pfad zu einer Datei (statt Verzeichnis) erzeugt einen Fehler."""
|
||
file = tmp_path / "file.md"
|
||
file.write_text("content", encoding="utf-8")
|
||
|
||
config = SourceConfig(
|
||
type="wissensdatenbank",
|
||
name="Test",
|
||
params={"output_path": str(file)},
|
||
)
|
||
result = source.extract(config, "bahn")
|
||
|
||
assert len(result.errors) == 1
|
||
assert result.errors[0].error_type == "parse"
|
||
assert result.errors[0].retry is False
|
||
|
||
def test_empty_directory(
|
||
self, source: WissensdatenbankSource, tmp_path: Path
|
||
) -> None:
|
||
"""Leeres Verzeichnis erzeugt leeres Ergebnis ohne Fehler."""
|
||
empty_dir = tmp_path / "empty"
|
||
empty_dir.mkdir()
|
||
|
||
config = SourceConfig(
|
||
type="wissensdatenbank",
|
||
name="Test",
|
||
params={"output_path": str(empty_dir)},
|
||
)
|
||
result = source.extract(config, "bahn")
|
||
|
||
assert len(result.artifacts) == 0
|
||
assert len(result.errors) == 0
|
||
assert result.skipped == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Kein OKF-Reformat (Req 13.4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestNoOkfReformat:
|
||
"""Tests, dass kein Google-OKF-Reformat erzwungen wird."""
|
||
|
||
def test_content_preserved_exactly(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Dateiinhalte werden unverändert als Content übernommen."""
|
||
result = source.extract(source_config, "bahn")
|
||
|
||
# Finde das Artefakt für kapitel-1
|
||
kapitel1 = next(
|
||
a for a in result.artifacts if "kapitel-1" in str(a.file_path)
|
||
)
|
||
expected_content = "# INB 2026 Kapitel 1\n\nInhalt des ersten Kapitels."
|
||
assert kapitel1.content == expected_content
|
||
|
||
def test_no_frontmatter_injection_in_original(
|
||
self,
|
||
source: WissensdatenbankSource,
|
||
source_config: SourceConfig,
|
||
wdb_dir: Path,
|
||
) -> None:
|
||
"""Originaldateien erhalten KEIN YAML-Frontmatter."""
|
||
source.extract(source_config, "bahn")
|
||
|
||
# Originaldatei prüfen – darf kein '---' am Anfang haben
|
||
original = wdb_dir / "inb-2026-kapitel-1.md"
|
||
content = original.read_text(encoding="utf-8")
|
||
assert not content.startswith("---")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: Import von __init__.py
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestModuleExport:
|
||
"""Tests, dass WissensdatenbankSource aus dem Modul exportiert wird."""
|
||
|
||
def test_importable_from_sources_package(self) -> None:
|
||
"""WissensdatenbankSource ist über das sources-Package importierbar."""
|
||
from monorepo.knowledge.sources import WissensdatenbankSource as WDB
|
||
|
||
assert WDB is WissensdatenbankSource
|