307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""Tests for project discovery logic."""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
from development_hub.project_discovery import Project, discover_projects, _load_project_config
|
|
|
|
|
|
class TestProject:
|
|
"""Tests for Project dataclass."""
|
|
|
|
def test_basic_project(self, tmp_path):
|
|
"""Create a basic project."""
|
|
project = Project(
|
|
key="my-project",
|
|
path=tmp_path / "my-project",
|
|
)
|
|
|
|
assert project.key == "my-project"
|
|
assert project.path == tmp_path / "my-project"
|
|
|
|
def test_title_derived_from_key(self):
|
|
"""Title is derived from key if not provided."""
|
|
project = Project(key="my-cool-project", path=Path("/tmp"))
|
|
assert project.title == "My Cool Project"
|
|
|
|
project2 = Project(key="snake_case_project", path=Path("/tmp"))
|
|
assert project2.title == "Snake Case Project"
|
|
|
|
def test_explicit_title(self):
|
|
"""Explicit title overrides derived title."""
|
|
project = Project(
|
|
key="proj",
|
|
path=Path("/tmp"),
|
|
title="Custom Title"
|
|
)
|
|
assert project.title == "Custom Title"
|
|
|
|
def test_exists_property(self, tmp_path):
|
|
"""exists property reflects directory existence."""
|
|
# Non-existent directory
|
|
project1 = Project(key="missing", path=tmp_path / "nonexistent")
|
|
assert project1.exists == False
|
|
|
|
# Create directory
|
|
existing_path = tmp_path / "existing"
|
|
existing_path.mkdir()
|
|
project2 = Project(key="existing", path=existing_path)
|
|
assert project2.exists == True
|
|
|
|
def test_gitea_url_with_owner_and_repo(self):
|
|
"""gitea_url is constructed when owner and repo are set."""
|
|
project = Project(
|
|
key="test",
|
|
path=Path("/tmp"),
|
|
owner="myuser",
|
|
repo="my-repo",
|
|
)
|
|
assert project.gitea_url == "https://gitea.brrd.tech/myuser/my-repo"
|
|
|
|
def test_gitea_url_empty_without_owner(self):
|
|
"""gitea_url is empty without owner/repo."""
|
|
project = Project(key="test", path=Path("/tmp"))
|
|
assert project.gitea_url == ""
|
|
|
|
def test_docs_url_with_owner_and_repo(self):
|
|
"""docs_url is constructed when owner and repo are set."""
|
|
project = Project(
|
|
key="test",
|
|
path=Path("/tmp"),
|
|
owner="rob",
|
|
repo="cmdforge",
|
|
)
|
|
assert project.docs_url == "https://pages.brrd.tech/rob/cmdforge/"
|
|
|
|
def test_dirname_defaults_to_key(self):
|
|
"""dirname defaults to key."""
|
|
project = Project(key="my-project", path=Path("/tmp"))
|
|
assert project.dirname == "my-project"
|
|
|
|
def test_explicit_dirname(self):
|
|
"""Explicit dirname is used."""
|
|
project = Project(
|
|
key="proj",
|
|
path=Path("/tmp"),
|
|
dirname="custom-dir"
|
|
)
|
|
assert project.dirname == "custom-dir"
|
|
|
|
|
|
class TestDiscoverProjects:
|
|
"""Tests for discover_projects function."""
|
|
|
|
@pytest.fixture
|
|
def mock_settings(self, tmp_path, monkeypatch):
|
|
"""Mock settings to use temp directory."""
|
|
from development_hub.settings import Settings
|
|
|
|
# Reset singleton
|
|
Settings._instance = None
|
|
|
|
settings_file = tmp_path / "settings.json"
|
|
session_file = tmp_path / "session.json"
|
|
|
|
monkeypatch.setattr(Settings, "_settings_file", settings_file)
|
|
monkeypatch.setattr(Settings, "_session_file", session_file)
|
|
|
|
# Create search directory
|
|
search_dir = tmp_path / "projects"
|
|
search_dir.mkdir()
|
|
|
|
settings = Settings()
|
|
settings.set("project_search_paths", [str(search_dir)])
|
|
settings.set("project_ignore_folders", ["node_modules", "__pycache__"])
|
|
|
|
yield settings, search_dir
|
|
|
|
Settings._instance = None
|
|
|
|
def test_discover_git_projects(self, mock_settings):
|
|
"""Discovers directories with .git folder."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create project with .git
|
|
proj_dir = search_dir / "my-project"
|
|
proj_dir.mkdir()
|
|
(proj_dir / ".git").mkdir()
|
|
|
|
projects = discover_projects()
|
|
|
|
assert len(projects) == 1
|
|
assert projects[0].key == "my-project"
|
|
|
|
def test_ignores_non_git_directories(self, mock_settings):
|
|
"""Ignores directories without .git."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create directory without .git
|
|
(search_dir / "not-a-project").mkdir()
|
|
|
|
projects = discover_projects()
|
|
assert len(projects) == 0
|
|
|
|
def test_ignores_hidden_directories(self, mock_settings):
|
|
"""Ignores directories starting with dot."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create hidden directory with .git
|
|
hidden = search_dir / ".hidden-project"
|
|
hidden.mkdir()
|
|
(hidden / ".git").mkdir()
|
|
|
|
projects = discover_projects()
|
|
assert len(projects) == 0
|
|
|
|
def test_ignores_configured_folders(self, mock_settings):
|
|
"""Ignores folders in ignore list."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create ignored folder with .git
|
|
ignored = search_dir / "node_modules"
|
|
ignored.mkdir()
|
|
(ignored / ".git").mkdir()
|
|
|
|
projects = discover_projects()
|
|
assert len(projects) == 0
|
|
|
|
def test_projects_sorted_by_title(self, mock_settings):
|
|
"""Projects are sorted alphabetically by title."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create projects in non-alphabetical order
|
|
for name in ["zebra", "apple", "mango"]:
|
|
proj = search_dir / name
|
|
proj.mkdir()
|
|
(proj / ".git").mkdir()
|
|
|
|
projects = discover_projects()
|
|
|
|
titles = [p.title for p in projects]
|
|
assert titles == ["Apple", "Mango", "Zebra"]
|
|
|
|
def test_multiple_search_paths(self, mock_settings, tmp_path):
|
|
"""Searches multiple configured paths."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create second search directory
|
|
search_dir2 = tmp_path / "more-projects"
|
|
search_dir2.mkdir()
|
|
|
|
# Create project in second directory
|
|
proj = search_dir2 / "second-proj"
|
|
proj.mkdir()
|
|
(proj / ".git").mkdir()
|
|
|
|
# Update settings with both paths
|
|
settings.set("project_search_paths", [str(search_dir), str(search_dir2)])
|
|
|
|
projects = discover_projects()
|
|
keys = {p.key for p in projects}
|
|
assert "second-proj" in keys
|
|
|
|
def test_deduplicates_by_key(self, mock_settings, tmp_path):
|
|
"""Same key in multiple paths only appears once."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create project in first path
|
|
proj1 = search_dir / "duplicate"
|
|
proj1.mkdir()
|
|
(proj1 / ".git").mkdir()
|
|
|
|
# Create another search path with same project name
|
|
search_dir2 = tmp_path / "more"
|
|
search_dir2.mkdir()
|
|
proj2 = search_dir2 / "duplicate"
|
|
proj2.mkdir()
|
|
(proj2 / ".git").mkdir()
|
|
|
|
settings.set("project_search_paths", [str(search_dir), str(search_dir2)])
|
|
|
|
projects = discover_projects()
|
|
duplicate_projects = [p for p in projects if p.key == "duplicate"]
|
|
assert len(duplicate_projects) == 1
|
|
|
|
def test_ignores_files(self, mock_settings):
|
|
"""Ignores files in search path."""
|
|
settings, search_dir = mock_settings
|
|
|
|
# Create a file
|
|
(search_dir / "some-file.txt").write_text("content")
|
|
|
|
projects = discover_projects()
|
|
assert len(projects) == 0
|
|
|
|
def test_handles_missing_search_path(self, mock_settings):
|
|
"""Handles non-existent search paths gracefully."""
|
|
settings, _ = mock_settings
|
|
|
|
settings.set("project_search_paths", ["/nonexistent/path"])
|
|
|
|
# Should not raise
|
|
projects = discover_projects()
|
|
assert len(projects) == 0
|
|
|
|
|
|
class TestLoadProjectConfig:
|
|
"""Tests for _load_project_config function."""
|
|
|
|
def test_returns_empty_when_no_file(self, monkeypatch, tmp_path):
|
|
"""Returns empty dict when build script doesn't exist."""
|
|
# Point to non-existent file
|
|
nonexistent = tmp_path / "nonexistent.sh"
|
|
monkeypatch.setattr(
|
|
"development_hub.project_discovery._load_project_config",
|
|
lambda: {} # Mock to return empty
|
|
)
|
|
|
|
# The actual function would return {} for missing file
|
|
result = _load_project_config()
|
|
# This test verifies the function handles missing files
|
|
# In practice, if the file doesn't exist, it returns {}
|
|
assert isinstance(result, dict)
|
|
|
|
def test_parses_project_config_format(self, tmp_path, monkeypatch):
|
|
"""Parses PROJECT_CONFIG entries from bash script."""
|
|
script_content = '''#!/bin/bash
|
|
PROJECT_CONFIG["cmdforge"]="CmdForge|AI-powered CLI tool builder|rob|cmdforge|CmdForge"
|
|
PROJECT_CONFIG["ramble"]="Ramble|Voice note transcription|rob|ramble|ramble"
|
|
'''
|
|
script_path = tmp_path / "build-public-docs.sh"
|
|
script_path.write_text(script_content)
|
|
|
|
# Mock the path in the function
|
|
import development_hub.project_discovery as pd
|
|
original_func = pd._load_project_config
|
|
|
|
def mock_load():
|
|
import re
|
|
pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
|
|
config = {}
|
|
for match in re.finditer(pattern, script_content):
|
|
key, title, tagline, owner, repo, dirname = match.groups()
|
|
config[key] = {
|
|
"title": title,
|
|
"tagline": tagline,
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"dirname": dirname,
|
|
}
|
|
return config
|
|
|
|
monkeypatch.setattr(pd, "_load_project_config", mock_load)
|
|
|
|
result = pd._load_project_config()
|
|
|
|
assert "cmdforge" in result
|
|
assert result["cmdforge"]["title"] == "CmdForge"
|
|
assert result["cmdforge"]["tagline"] == "AI-powered CLI tool builder"
|
|
assert result["cmdforge"]["owner"] == "rob"
|
|
|
|
assert "ramble" in result
|
|
assert result["ramble"]["title"] == "Ramble"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|