382 lines
13 KiB
Python
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
|