Files
2026-06-30 20:37:40 +02:00

382 lines
13 KiB
Python

"""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:<hex>'."""
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