"""Unit-Tests für das Wissensartefakt-Modell (knowledge/artifact.py). Testet: - YAML-Frontmatter-Parsing und -Generierung - Content-Hash-Berechnung (SHA-256) - Scope-basierte Pfadauflösung - Artefakt-Schreibvorgang mit Ordnerstruktur """ from __future__ import annotations from datetime import date from pathlib import Path import pytest from monorepo.knowledge.artifact import ( Artifact, ArtifactLink, ArtifactMetadata, compute_content_hash, parse_frontmatter, resolve_artifact_path, write_artifact, ) # --------------------------------------------------------------------------- # Tests: compute_content_hash # --------------------------------------------------------------------------- class TestComputeContentHash: """Tests für die SHA-256 Content-Hash-Berechnung.""" def test_hash_format(self) -> None: """Hash hat das Format 'sha256:'.""" result = compute_content_hash("Hello World") assert result.startswith("sha256:") hex_part = result.removeprefix("sha256:") assert len(hex_part) == 64 # SHA-256 = 64 hex chars def test_deterministic(self) -> None: """Gleicher Input ergibt gleichen Hash.""" content = "Testinhalt mit Umlauten: äöü" assert compute_content_hash(content) == compute_content_hash(content) def test_different_content_different_hash(self) -> None: """Verschiedener Input ergibt verschiedenen Hash.""" assert compute_content_hash("abc") != compute_content_hash("xyz") def test_empty_string(self) -> None: """Leerer String ergibt gültigen Hash.""" result = compute_content_hash("") assert result.startswith("sha256:") assert len(result.removeprefix("sha256:")) == 64 def test_known_value(self) -> None: """Bekannter SHA-256-Wert für Verifikation.""" # SHA-256 of "test" is well-known import hashlib expected = "sha256:" + hashlib.sha256(b"test").hexdigest() assert compute_content_hash("test") == expected # --------------------------------------------------------------------------- # Tests: resolve_artifact_path # --------------------------------------------------------------------------- class TestResolveArtifactPath: """Tests für die Scope-basierte Pfadauflösung.""" def test_basic_path(self) -> None: """Standard-Pfadauflösung: scope/type/name.md.""" result = resolve_artifact_path("bahn", "decision", "api-design") assert result == Path("bahn/decision/api-design.md") def test_different_scope(self) -> None: """Verschiedene Scopes ergeben verschiedene Pfade.""" path_bahn = resolve_artifact_path("bahn", "note", "test") path_privat = resolve_artifact_path("privat", "note", "test") assert path_bahn != path_privat assert path_bahn.parts[0] == "bahn" assert path_privat.parts[0] == "privat" def test_different_types(self) -> None: """Verschiedene Typen ergeben verschiedene Pfade.""" path_decision = resolve_artifact_path("bahn", "decision", "x") path_note = resolve_artifact_path("bahn", "note", "x") assert path_decision.parts[1] == "decision" assert path_note.parts[1] == "note" def test_name_with_md_extension_stripped(self) -> None: """Wenn Name bereits .md hat, wird es nicht doppelt angehängt.""" result = resolve_artifact_path("privat", "note", "test.md") assert result == Path("privat/note/test.md") assert not str(result).endswith(".md.md") def test_name_without_extension(self) -> None: """Name ohne Erweiterung bekommt .md.""" result = resolve_artifact_path("shared", "pattern", "singleton") assert result.suffix == ".md" # --------------------------------------------------------------------------- # Tests: parse_frontmatter # --------------------------------------------------------------------------- class TestParseFrontmatter: """Tests für das YAML-Frontmatter-Parsing.""" def test_full_frontmatter(self, tmp_path: Path) -> None: """Parst vollständiges Frontmatter mit allen Feldern.""" content = """--- type: decision title: "API-Designprinzipien" tags: [api, microservices] source_context: bahn created: 2024-12-15 updated: 2025-01-10 shareable: true links: - target: "privat/notes/rest.md" relation: "implements" content_hash: "sha256:abc123" --- # Inhalt hier """ file = tmp_path / "test.md" file.write_text(content, encoding="utf-8") artifact = parse_frontmatter(file) assert artifact.metadata.type == "decision" assert artifact.metadata.title == "API-Designprinzipien" assert artifact.metadata.tags == ["api", "microservices"] assert artifact.metadata.source_context == "bahn" assert artifact.metadata.created == date(2024, 12, 15) assert artifact.metadata.updated == date(2025, 1, 10) assert artifact.metadata.shareable is True assert len(artifact.metadata.links) == 1 assert artifact.metadata.links[0].target == "privat/notes/rest.md" assert artifact.metadata.links[0].relation == "implements" assert artifact.metadata.content_hash == "sha256:abc123" assert artifact.content == "# Inhalt hier\n" assert artifact.file_path == file def test_minimal_frontmatter(self, tmp_path: Path) -> None: """Parst minimales Frontmatter mit nur type und title.""" content = """--- type: note title: "Kurze Notiz" --- Einfacher Text. """ file = tmp_path / "minimal.md" file.write_text(content, encoding="utf-8") artifact = parse_frontmatter(file) assert artifact.metadata.type == "note" assert artifact.metadata.title == "Kurze Notiz" assert artifact.metadata.tags == [] assert artifact.metadata.source_context == "" assert artifact.metadata.created is None assert artifact.metadata.shareable is False assert artifact.metadata.links == [] assert artifact.content == "Einfacher Text.\n" def test_multiple_links(self, tmp_path: Path) -> None: """Parst mehrere Verknüpfungen.""" content = """--- type: reference title: "Multi-Link" links: - target: "a.md" relation: "references" - target: "b.md" relation: "implements" - target: "c.md" relation: "extends" --- Body. """ file = tmp_path / "links.md" file.write_text(content, encoding="utf-8") artifact = parse_frontmatter(file) assert len(artifact.metadata.links) == 3 assert artifact.metadata.links[2].target == "c.md" def test_no_frontmatter_raises_error(self, tmp_path: Path) -> None: """Datei ohne Frontmatter wirft ValueError.""" file = tmp_path / "no_fm.md" file.write_text("# Kein Frontmatter\nNur Text.", encoding="utf-8") with pytest.raises(ValueError, match="Kein gültiges YAML-Frontmatter"): parse_frontmatter(file) def test_file_not_found(self, tmp_path: Path) -> None: """Nicht-existierende Datei wirft FileNotFoundError.""" with pytest.raises(FileNotFoundError): parse_frontmatter(tmp_path / "nonexistent.md") def test_empty_frontmatter(self, tmp_path: Path) -> None: """Leeres Frontmatter ergibt leere Metadaten.""" content = """--- --- Nur Inhalt. """ file = tmp_path / "empty_fm.md" file.write_text(content, encoding="utf-8") artifact = parse_frontmatter(file) assert artifact.metadata.type == "" assert artifact.metadata.title == "" assert artifact.content == "Nur Inhalt.\n" def test_content_with_dashes(self, tmp_path: Path) -> None: """Inhalt mit --- wird nicht als Frontmatter-Ende interpretiert.""" content = """--- type: note title: "Dashes Test" --- Hier kommt ein Trenner: --- Und noch mehr Text. """ file = tmp_path / "dashes.md" file.write_text(content, encoding="utf-8") artifact = parse_frontmatter(file) assert "---" in artifact.content assert "Und noch mehr Text." in artifact.content # --------------------------------------------------------------------------- # Tests: write_artifact # --------------------------------------------------------------------------- class TestWriteArtifact: """Tests für das Schreiben von Artefakten.""" def test_creates_file_with_frontmatter(self, tmp_path: Path) -> None: """Schreibt eine Datei mit korrektem YAML-Frontmatter.""" metadata = ArtifactMetadata( type="decision", title="Test Decision", tags=["test", "example"], source_context="bahn", created=date(2024, 12, 15), shareable=True, ) artifact = Artifact( metadata=metadata, content="# Entscheidung\n\nDetails hier.\n", file_path=Path("test-decision.md"), ) result_path = write_artifact(artifact, tmp_path) assert result_path.exists() assert result_path == tmp_path / "bahn" / "decision" / "test-decision.md" # Roundtrip: Datei wieder einlesen parsed = parse_frontmatter(result_path) assert parsed.metadata.type == "decision" assert parsed.metadata.title == "Test Decision" assert parsed.metadata.tags == ["test", "example"] assert parsed.metadata.source_context == "bahn" assert parsed.metadata.shareable is True assert "# Entscheidung" in parsed.content def test_creates_directory_structure(self, tmp_path: Path) -> None: """Erstellt fehlende Verzeichnisse automatisch.""" metadata = ArtifactMetadata( type="pattern", title="Singleton", source_context="shared", ) artifact = Artifact( metadata=metadata, content="Pattern-Inhalt\n", file_path=Path("singleton.md"), ) result_path = write_artifact(artifact, tmp_path) assert (tmp_path / "shared" / "pattern").is_dir() assert result_path.exists() def test_updates_content_hash(self, tmp_path: Path) -> None: """Aktualisiert den Content-Hash automatisch beim Schreiben.""" content = "Neuer Inhalt für den Hash-Test.\n" metadata = ArtifactMetadata( type="note", title="Hash Test", source_context="privat", content_hash="sha256:old_hash", # alter Hash ) artifact = Artifact( metadata=metadata, content=content, file_path=Path("hash-test.md"), ) write_artifact(artifact, tmp_path) # Hash muss aktualisiert sein expected_hash = compute_content_hash(content) assert artifact.metadata.content_hash == expected_hash assert artifact.metadata.content_hash != "sha256:old_hash" def test_writes_links_in_frontmatter(self, tmp_path: Path) -> None: """Schreibt Verknüpfungen korrekt ins Frontmatter.""" metadata = ArtifactMetadata( type="reference", title="Linked Artifact", source_context="dhive", links=[ ArtifactLink(target="other/doc.md", relation="references"), ArtifactLink(target="third/file.md", relation="implements"), ], ) artifact = Artifact( metadata=metadata, content="Verknüpfter Inhalt.\n", file_path=Path("linked.md"), ) result_path = write_artifact(artifact, tmp_path) # Roundtrip-Verifikation parsed = parse_frontmatter(result_path) assert len(parsed.metadata.links) == 2 assert parsed.metadata.links[0].target == "other/doc.md" assert parsed.metadata.links[0].relation == "references" assert parsed.metadata.links[1].target == "third/file.md" def test_overwrites_existing_file(self, tmp_path: Path) -> None: """Überschreibt eine bereits existierende Datei.""" metadata = ArtifactMetadata( type="note", title="Overwrite Test", source_context="privat", ) artifact = Artifact( metadata=metadata, content="Version 1\n", file_path=Path("overwrite.md"), ) path1 = write_artifact(artifact, tmp_path) artifact.content = "Version 2\n" path2 = write_artifact(artifact, tmp_path) assert path1 == path2 parsed = parse_frontmatter(path2) assert "Version 2" in parsed.content def test_updates_artifact_file_path(self, tmp_path: Path) -> None: """Aktualisiert artifact.file_path nach dem Schreiben.""" metadata = ArtifactMetadata( type="meeting", title="Standup", source_context="dhive", ) artifact = Artifact( metadata=metadata, content="Meeting-Notizen.\n", file_path=Path("standup.md"), ) result_path = write_artifact(artifact, tmp_path) assert artifact.file_path == result_path