Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"""Property-basierte Tests für den Wissensspeicher: Artefakt-Ingestion.
|
||||
|
||||
**Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21**
|
||||
|
||||
Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index
|
||||
- For any gültiges Wissensartefakt aus einem beliebigen Kontext, nach der Verarbeitung
|
||||
durch die ETL-Pipeline muss es im YAML-Index erscheinen mit korrektem YAML-Frontmatter
|
||||
(mindestens Typ, Titel, Tags, Quellkontext, Erstelldatum, Content-Hash) und der Pfad
|
||||
muss der Scope-basierten Ordnerstruktur entsprechen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from monorepo.knowledge.etl import ETLPipeline
|
||||
from monorepo.knowledge.index import YAMLIndex
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource
|
||||
from monorepo.models import ArtifactType, Context
|
||||
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
# Valid artifact types from the model
|
||||
VALID_ARTIFACT_TYPES = [t.value for t in ArtifactType]
|
||||
|
||||
# Valid contexts (non-shared, as source contexts for artifacts)
|
||||
VALID_CONTEXTS = [c.value for c in Context if c != Context.SHARED]
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_tag(draw: st.DrawFn) -> str:
|
||||
"""Generates a valid tag: lowercase alphanumeric with optional hyphens."""
|
||||
return draw(
|
||||
st.from_regex(r"[a-z][a-z0-9\-]{0,14}[a-z0-9]", fullmatch=True)
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_artifact_name(draw: st.DrawFn) -> str:
|
||||
"""Generates a valid artifact filename (kebab-case, no extension)."""
|
||||
return draw(
|
||||
st.from_regex(r"[a-z][a-z0-9\-]{1,20}[a-z0-9]", fullmatch=True)
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_title(draw: st.DrawFn) -> str:
|
||||
"""Generates a valid artifact title (non-empty, printable)."""
|
||||
return draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "Z"),
|
||||
whitelist_characters="-_ ",
|
||||
),
|
||||
min_size=3,
|
||||
max_size=60,
|
||||
).filter(lambda t: t.strip() != "")
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_markdown_artifact(draw: st.DrawFn) -> dict[str, object]:
|
||||
"""Generates a complete valid Markdown artifact with frontmatter.
|
||||
|
||||
Returns a dict with:
|
||||
- name: filename without .md extension
|
||||
- type: artifact type (decision, note, etc.)
|
||||
- title: artifact title
|
||||
- tags: list of tags
|
||||
- content: markdown body content
|
||||
- context: source context
|
||||
"""
|
||||
name = draw(valid_artifact_name())
|
||||
artifact_type = draw(st.sampled_from(VALID_ARTIFACT_TYPES))
|
||||
title = draw(valid_title())
|
||||
tags = draw(st.lists(valid_tag(), min_size=1, max_size=5, unique=True))
|
||||
context = draw(st.sampled_from(VALID_CONTEXTS))
|
||||
|
||||
# Generate some markdown body content
|
||||
body_line = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "Z"),
|
||||
whitelist_characters="-_., ",
|
||||
),
|
||||
min_size=10,
|
||||
max_size=100,
|
||||
).filter(lambda t: t.strip() != "")
|
||||
)
|
||||
content = f"\n# {title}\n\n{body_line}\n"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"type": artifact_type,
|
||||
"title": title,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
|
||||
def _write_markdown_file(directory: Path, artifact: dict[str, object]) -> Path:
|
||||
"""Writes a Markdown file with YAML frontmatter to the given directory.
|
||||
|
||||
Returns the path to the written file.
|
||||
"""
|
||||
name: str = artifact["name"] # type: ignore[assignment]
|
||||
artifact_type: str = artifact["type"] # type: ignore[assignment]
|
||||
title: str = artifact["title"] # type: ignore[assignment]
|
||||
tags: list[str] = artifact["tags"] # type: ignore[assignment]
|
||||
content: str = artifact["content"] # type: ignore[assignment]
|
||||
|
||||
# Build YAML frontmatter
|
||||
tags_str = ", ".join(tags)
|
||||
frontmatter = (
|
||||
f"---\n"
|
||||
f"type: {artifact_type}\n"
|
||||
f"title: \"{title}\"\n"
|
||||
f"tags: [{tags_str}]\n"
|
||||
f"---\n"
|
||||
)
|
||||
|
||||
file_path = directory / f"{name}.md"
|
||||
file_path.write_text(frontmatter + content, encoding="utf-8")
|
||||
return file_path
|
||||
|
||||
|
||||
# --- Property Test ---
|
||||
|
||||
|
||||
class TestProperty5ArtifactIngestionMetadata:
|
||||
"""Property 5: Wissensartefakt-Ingestion erzeugt vollständige Metadaten im Index.
|
||||
|
||||
**Validates: Requirements 3.2, 3.4, 3.6, 3.11, 3.13, 3.17, 3.21**
|
||||
|
||||
For any valid knowledge artifact from any context, after processing by the
|
||||
ETL pipeline, it must appear in the YAML index with:
|
||||
- id set
|
||||
- title matches
|
||||
- type matches
|
||||
- tags match
|
||||
- scope == context
|
||||
- content_hash starts with "sha256:"
|
||||
- path follows scope-based structure ({context}/{type}/{name}.md)
|
||||
"""
|
||||
|
||||
@given(artifact=valid_markdown_artifact())
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_ingested_artifact_has_complete_metadata_in_index(
|
||||
self, artifact: dict[str, object], tmp_path_factory: object
|
||||
) -> None:
|
||||
"""After ingestion, the artifact appears in the YAML index with all required metadata."""
|
||||
# Use a unique temp directory for each test invocation
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
source_dir = tmp / "source"
|
||||
source_dir.mkdir()
|
||||
store_path = tmp / "store"
|
||||
store_path.mkdir()
|
||||
|
||||
# Extract artifact data
|
||||
name: str = artifact["name"] # type: ignore[assignment]
|
||||
artifact_type: str = artifact["type"] # type: ignore[assignment]
|
||||
title: str = artifact["title"] # type: ignore[assignment]
|
||||
tags: list[str] = artifact["tags"] # type: ignore[assignment]
|
||||
context: str = artifact["context"] # type: ignore[assignment]
|
||||
|
||||
# Write the artifact as a Markdown file
|
||||
_write_markdown_file(source_dir, artifact)
|
||||
|
||||
# Set up ETL pipeline
|
||||
index = YAMLIndex(store_path / "_index.yaml")
|
||||
index.load()
|
||||
pipeline = ETLPipeline(index=index, store_path=store_path)
|
||||
source = MarkdownSource(directory=source_dir)
|
||||
|
||||
# Run ingestion
|
||||
result = pipeline.ingest(source, context)
|
||||
|
||||
# --- Assertions ---
|
||||
# The artifact was processed successfully
|
||||
assert result.processed == 1, (
|
||||
f"Expected 1 processed artifact, got {result.processed}. "
|
||||
f"Errors: {result.errors}"
|
||||
)
|
||||
assert result.updated == 1, (
|
||||
f"Expected 1 updated artifact, got {result.updated}."
|
||||
)
|
||||
|
||||
# The artifact appears in the YAML index
|
||||
expected_id = f"{context}/{artifact_type}/{name}"
|
||||
entry = index.get_entry(expected_id)
|
||||
assert entry is not None, (
|
||||
f"Artifact '{expected_id}' not found in index. "
|
||||
f"Available entries: {list(index.entries.keys())}"
|
||||
)
|
||||
|
||||
# id is set
|
||||
assert entry.id == expected_id
|
||||
|
||||
# title matches
|
||||
assert entry.title == title
|
||||
|
||||
# type matches
|
||||
assert entry.type == artifact_type
|
||||
|
||||
# tags match (order-independent)
|
||||
assert set(entry.tags) == set(tags), (
|
||||
f"Tags mismatch: expected {tags}, got {entry.tags}"
|
||||
)
|
||||
|
||||
# scope == context
|
||||
assert entry.scope == context, (
|
||||
f"Scope mismatch: expected '{context}', got '{entry.scope}'"
|
||||
)
|
||||
|
||||
# content_hash starts with "sha256:"
|
||||
assert entry.content_hash.startswith("sha256:"), (
|
||||
f"Content hash should start with 'sha256:', got '{entry.content_hash}'"
|
||||
)
|
||||
# Content hash has actual hex digest after prefix
|
||||
hex_part = entry.content_hash[len("sha256:"):]
|
||||
assert len(hex_part) == 64, (
|
||||
f"SHA-256 hex digest should be 64 chars, got {len(hex_part)}"
|
||||
)
|
||||
|
||||
# path follows scope-based structure: {context}/{type}/{name}.md
|
||||
# Normalize path separators (Windows uses backslashes in Path objects)
|
||||
normalized_path = entry.path.replace("\\", "/")
|
||||
expected_path = f"{context}/{artifact_type}/{name}.md"
|
||||
assert normalized_path == expected_path, (
|
||||
f"Path mismatch: expected '{expected_path}', got '{entry.path}'"
|
||||
)
|
||||
Reference in New Issue
Block a user