Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,373 @@
"""Unit-Tests für den StructureManager.
Testet die Verwaltung der 3-Ebenen-Ordnerhierarchie:
- Namensvalidierung (kebab-case, 2-50 Zeichen)
- Projekterstellung in allen vier Kontexten
- Fehlermeldung bei existierendem Projektnamen
- Edge Cases: Minimum-/Maximum-Länge, Sonderzeichen, Unicode
Validates: Requirements 1.1, 1.2, 1.3, 1.6
"""
from __future__ import annotations
import pytest
from pathlib import Path
from monorepo.structure import StructureManager
from monorepo.models import ProjectInfo
@pytest.fixture
def manager(tmp_path: Path) -> StructureManager:
"""Erstellt einen StructureManager mit einem temporären Root-Verzeichnis."""
return StructureManager(root_path=tmp_path)
# ---------------------------------------------------------------------------
# 1. validate_name: gültige kebab-case Namen
# ---------------------------------------------------------------------------
class TestValidateNameAccepts:
"""Testet, dass validate_name gültige Namen akzeptiert."""
def test_simple_name(self, manager: StructureManager) -> None:
assert manager.validate_name("my-project") is True
def test_minimum_length_two_chars(self, manager: StructureManager) -> None:
"""Minimum-Länge: 2 Zeichen (z.B. 'ab')."""
assert manager.validate_name("ab") is True
def test_maximum_length_fifty_chars(self, manager: StructureManager) -> None:
"""Maximum-Länge: 50 Zeichen."""
name = "a" * 50
assert manager.validate_name(name) is True
def test_starts_with_digit(self, manager: StructureManager) -> None:
assert manager.validate_name("1project") is True
def test_ends_with_digit(self, manager: StructureManager) -> None:
assert manager.validate_name("project1") is True
def test_only_digits(self, manager: StructureManager) -> None:
assert manager.validate_name("12") is True
def test_with_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("my-cool-project") is True
def test_mixed_letters_digits_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("a1-b2-c3") is True
# ---------------------------------------------------------------------------
# 2. validate_name: Ablehnungen (ungültige Namen)
# ---------------------------------------------------------------------------
class TestValidateNameRejects:
"""Testet, dass validate_name ungültige Namen ablehnt."""
def test_single_char(self, manager: StructureManager) -> None:
"""Einzelnes Zeichen ist zu kurz (< 2)."""
assert manager.validate_name("a") is False
def test_fifty_one_chars(self, manager: StructureManager) -> None:
"""51 Zeichen überschreitet Maximum."""
name = "a" * 51
assert manager.validate_name(name) is False
def test_starts_with_hyphen(self, manager: StructureManager) -> None:
assert manager.validate_name("-project") is False
def test_ends_with_hyphen(self, manager: StructureManager) -> None:
assert manager.validate_name("project-") is False
def test_uppercase_letters(self, manager: StructureManager) -> None:
assert manager.validate_name("MyProject") is False
def test_spaces(self, manager: StructureManager) -> None:
assert manager.validate_name("my project") is False
def test_underscores(self, manager: StructureManager) -> None:
assert manager.validate_name("my_project") is False
def test_unicode_characters(self, manager: StructureManager) -> None:
assert manager.validate_name("mein-prüfprojekt") is False
def test_empty_string(self, manager: StructureManager) -> None:
assert manager.validate_name("") is False
def test_only_hyphens(self, manager: StructureManager) -> None:
assert manager.validate_name("---") is False
def test_special_characters(self, manager: StructureManager) -> None:
assert manager.validate_name("my@project") is False
def test_dots(self, manager: StructureManager) -> None:
assert manager.validate_name("my.project") is False
# ---------------------------------------------------------------------------
# 3. create_project: Erstellung in jedem der 4 Kontexte
# ---------------------------------------------------------------------------
class TestCreateProjectContexts:
"""Testet Projekterstellung in allen vier Kontexten."""
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_create_project_in_each_context(
self, manager: StructureManager, context: str
) -> None:
"""Projekt kann in jedem gültigen Kontext angelegt werden."""
result = manager.create_project(context, "test-project")
assert result.exists()
assert result.is_dir()
assert result == manager.root_path / context / "test-project"
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_create_project_returns_correct_path(
self, manager: StructureManager, context: str
) -> None:
"""Der zurückgegebene Pfad zeigt auf den erstellten Ordner."""
result = manager.create_project(context, "my-app")
expected = manager.root_path / context / "my-app"
assert result == expected
# ---------------------------------------------------------------------------
# 4. create_project: ValueError bei ungültigem Kontext
# ---------------------------------------------------------------------------
class TestCreateProjectInvalidContext:
"""Testet Fehlermeldung bei ungültigem Kontext."""
def test_invalid_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("invalid", "my-project")
def test_empty_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("", "my-project")
def test_capitalized_context_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.create_project("Privat", "my-project")
# ---------------------------------------------------------------------------
# 5. create_project: ValueError bei ungültigem Projektnamen
# ---------------------------------------------------------------------------
class TestCreateProjectInvalidName:
"""Testet Fehlermeldung bei ungültigem Projektnamen."""
def test_invalid_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "My_Project")
def test_single_char_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "a")
def test_empty_name_raises_valueerror(self, manager: StructureManager) -> None:
with pytest.raises(ValueError, match="Ungültiger Projektname"):
manager.create_project("privat", "")
# ---------------------------------------------------------------------------
# 6. create_project: ValueError bei doppeltem Projektnamen (Namenskonflikt)
# ---------------------------------------------------------------------------
class TestCreateProjectDuplicate:
"""Testet die Fehlermeldung bei existierendem Projektnamen."""
def test_duplicate_raises_valueerror(self, manager: StructureManager) -> None:
"""Zweites Anlegen des gleichen Projekts schlägt fehl."""
manager.create_project("privat", "my-project")
with pytest.raises(ValueError, match="Namenskonflikt"):
manager.create_project("privat", "my-project")
def test_duplicate_error_mentions_name(self, manager: StructureManager) -> None:
"""Die Fehlermeldung nennt den kollidierenden Namen."""
manager.create_project("dhive", "existing-app")
with pytest.raises(ValueError, match="existing-app"):
manager.create_project("dhive", "existing-app")
def test_same_name_different_context_works(self, manager: StructureManager) -> None:
"""Gleicher Name in verschiedenen Kontexten ist erlaubt."""
path1 = manager.create_project("privat", "my-tool")
path2 = manager.create_project("dhive", "my-tool")
assert path1.exists()
assert path2.exists()
assert path1 != path2
def test_duplicate_does_not_modify_existing(self, manager: StructureManager) -> None:
"""Ein fehlgeschlagener Duplikat-Versuch verändert nichts."""
path = manager.create_project("bahn", "my-service")
# Datei im bestehenden Projekt erstellen
marker = path / "marker.txt"
marker.write_text("original")
with pytest.raises(ValueError):
manager.create_project("bahn", "my-service")
# Bestehender Inhalt bleibt unverändert
assert marker.read_text() == "original"
# ---------------------------------------------------------------------------
# 7. list_projects: Auflistung und Filterung
# ---------------------------------------------------------------------------
class TestListProjects:
"""Testet die Projektauflistung und Kontextfilterung."""
def test_list_all_projects(self, manager: StructureManager) -> None:
"""Listet alle Projekte aus allen Kontexten."""
manager.create_project("privat", "app-a")
manager.create_project("dhive", "app-b")
manager.create_project("bahn", "app-c")
projects = manager.list_projects()
names = [p.name for p in projects]
assert "app-a" in names
assert "app-b" in names
assert "app-c" in names
def test_list_filtered_by_context(self, manager: StructureManager) -> None:
"""Filterung nach Kontext liefert nur Projekte des Kontexts."""
manager.create_project("privat", "priv-app")
manager.create_project("dhive", "dhive-app")
projects = manager.list_projects(context="privat")
names = [p.name for p in projects]
assert "priv-app" in names
assert "dhive-app" not in names
def test_list_returns_project_info(self, manager: StructureManager) -> None:
"""Jedes Element enthält name, context und path."""
manager.create_project("shared", "common-lib")
projects = manager.list_projects(context="shared")
assert len(projects) == 1
proj = projects[0]
assert proj.name == "common-lib"
assert proj.context == "shared"
assert proj.path == manager.root_path / "shared" / "common-lib"
def test_list_empty_when_no_projects(self, manager: StructureManager) -> None:
"""Leere Liste wenn keine Projekte existieren."""
projects = manager.list_projects()
assert projects == []
def test_list_invalid_context_raises(self, manager: StructureManager) -> None:
"""Ungültiger Kontextfilter löst ValueError aus."""
with pytest.raises(ValueError, match="Ungültiger Kontext"):
manager.list_projects(context="invalid")
# ---------------------------------------------------------------------------
# 8. list_projects: Überspringt versteckte Verzeichnisse
# ---------------------------------------------------------------------------
class TestListProjectsSkipsHidden:
"""Testet, dass versteckte Verzeichnisse (. prefix) übersprungen werden."""
def test_skips_dotfiles(self, manager: StructureManager) -> None:
"""Verzeichnisse mit . Prefix werden nicht gelistet."""
manager.create_project("privat", "visible-app")
# Verstecktes Verzeichnis manuell erstellen
hidden_dir = manager.root_path / "privat" / ".hidden-dir"
hidden_dir.mkdir(parents=True)
projects = manager.list_projects(context="privat")
names = [p.name for p in projects]
assert "visible-app" in names
assert ".hidden-dir" not in names
def test_skips_git_directory(self, manager: StructureManager) -> None:
"""Das .git Verzeichnis wird übersprungen."""
manager.create_project("dhive", "real-project")
git_dir = manager.root_path / "dhive" / ".git"
git_dir.mkdir(parents=True)
projects = manager.list_projects(context="dhive")
names = [p.name for p in projects]
assert "real-project" in names
assert ".git" not in names
# ---------------------------------------------------------------------------
# 9. resolve_context: Korrekte Kontexterkennung
# ---------------------------------------------------------------------------
class TestResolveContext:
"""Testet die Kontextauflösung anhand eines Pfads."""
@pytest.mark.parametrize("context", ["privat", "dhive", "bahn", "shared"])
def test_resolve_each_context(
self, manager: StructureManager, context: str
) -> None:
"""Erkennt den Kontext für Pfade in jedem Kontextordner."""
project_path = manager.create_project(context, "test-proj")
resolved = manager.resolve_context(project_path)
assert resolved == context
def test_resolve_nested_path(self, manager: StructureManager) -> None:
"""Erkennt den Kontext auch für tiefere Pfade."""
project_path = manager.create_project("bahn", "deep-proj")
module_path = project_path / "module-a" / "subdir"
module_path.mkdir(parents=True)
resolved = manager.resolve_context(module_path)
assert resolved == "bahn"
# ---------------------------------------------------------------------------
# 10. resolve_context: ValueError bei Pfaden außerhalb des Monorepos
# ---------------------------------------------------------------------------
class TestResolveContextOutside:
"""Testet Fehlermeldung bei Pfaden außerhalb des Monorepos."""
def test_outside_monorepo_raises(self, manager: StructureManager, tmp_path: Path) -> None:
"""Pfad außerhalb des Monorepo-Root löst ValueError aus."""
outside_path = tmp_path.parent / "some-other-dir"
with pytest.raises(ValueError):
manager.resolve_context(outside_path)
def test_root_path_itself_raises(self, manager: StructureManager) -> None:
"""Das Monorepo-Root selbst ist kein Projekt."""
with pytest.raises(ValueError):
manager.resolve_context(manager.root_path)
def test_unknown_context_folder_raises(self, manager: StructureManager) -> None:
"""Unbekannter Ordnername auf Ebene 1 löst ValueError aus."""
unknown = manager.root_path / "unknown-context" / "some-project"
unknown.mkdir(parents=True)
with pytest.raises(ValueError, match="nicht in einem bekannten"):
manager.resolve_context(unknown)