"""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)