284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Tests for ArchitectureTracker."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app_factory.core.architecture_tracker import ArchitectureTracker
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def tmp_data_dir(tmp_path):
|
|
"""Temporary data directory."""
|
|
return str(tmp_path / "data")
|
|
|
|
|
|
@pytest.fixture
|
|
def tracker(tmp_data_dir):
|
|
"""ArchitectureTracker with no Claude SDK client."""
|
|
return ArchitectureTracker(data_dir=tmp_data_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def tracker_with_client(tmp_data_dir):
|
|
"""ArchitectureTracker with a mocked Claude SDK client."""
|
|
mock_client = AsyncMock()
|
|
t = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
t._client = mock_client
|
|
return t, mock_client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Initialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInitialization:
|
|
def test_creates_default_architecture(self, tracker):
|
|
arch = tracker._architecture
|
|
assert "modules" in arch
|
|
assert "utilities" in arch
|
|
assert "design_patterns" in arch
|
|
assert "naming_conventions" in arch
|
|
assert "tech_stack" in arch
|
|
assert arch["version"] == 1
|
|
assert "last_updated" in arch
|
|
|
|
def test_creates_data_directory(self, tmp_data_dir):
|
|
tracker = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
assert os.path.isdir(tmp_data_dir)
|
|
|
|
def test_default_architecture_has_empty_lists(self, tracker):
|
|
assert tracker._architecture["modules"] == []
|
|
assert tracker._architecture["utilities"] == []
|
|
assert tracker._architecture["design_patterns"] == []
|
|
|
|
def test_default_naming_conventions(self, tracker):
|
|
conventions = tracker._architecture["naming_conventions"]
|
|
assert conventions["variables"] == "snake_case"
|
|
assert conventions["classes"] == "PascalCase"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Load / Save Persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPersistence:
|
|
def test_save_and_load(self, tmp_data_dir):
|
|
tracker1 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
tracker1.add_module("TestModule", "A test module", "test.py")
|
|
|
|
tracker2 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
assert len(tracker2._architecture["modules"]) == 1
|
|
assert tracker2._architecture["modules"][0]["name"] == "TestModule"
|
|
|
|
def test_save_updates_timestamp(self, tracker):
|
|
old_ts = tracker._architecture.get("last_updated", "")
|
|
tracker.save_architecture(tracker._architecture)
|
|
new_ts = tracker._architecture["last_updated"]
|
|
# Timestamp should be updated (or at least present)
|
|
assert new_ts is not None
|
|
assert len(new_ts) > 0
|
|
|
|
def test_load_corrupt_file_returns_default(self, tmp_data_dir):
|
|
os.makedirs(tmp_data_dir, exist_ok=True)
|
|
corrupt_path = os.path.join(tmp_data_dir, "global_architecture.json")
|
|
with open(corrupt_path, "w") as f:
|
|
f.write("NOT VALID JSON {{{")
|
|
|
|
tracker = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
assert tracker._architecture["version"] == 1
|
|
assert tracker._architecture["modules"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# add_module
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAddModule:
|
|
def test_add_module(self, tracker):
|
|
tracker.add_module("MyModule", "Does something", "src/my_module.py")
|
|
assert len(tracker._architecture["modules"]) == 1
|
|
mod = tracker._architecture["modules"][0]
|
|
assert mod["name"] == "MyModule"
|
|
assert mod["purpose"] == "Does something"
|
|
assert mod["file_path"] == "src/my_module.py"
|
|
|
|
def test_add_module_persists(self, tmp_data_dir):
|
|
t1 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
t1.add_module("Persisted", "persists", "p.py")
|
|
|
|
t2 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
assert any(m["name"] == "Persisted" for m in t2._architecture["modules"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# add_utility
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAddUtility:
|
|
def test_add_utility(self, tracker):
|
|
tracker.add_utility("helper_func", "Helps with things", "utils.py")
|
|
assert len(tracker._architecture["utilities"]) == 1
|
|
util = tracker._architecture["utilities"][0]
|
|
assert util["name"] == "helper_func"
|
|
assert util["description"] == "Helps with things"
|
|
assert util["file_path"] == "utils.py"
|
|
|
|
def test_add_utility_persists(self, tmp_data_dir):
|
|
t1 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
t1.add_utility("persisted_func", "persists", "p.py")
|
|
|
|
t2 = ArchitectureTracker(data_dir=tmp_data_dir)
|
|
assert any(u["name"] == "persisted_func" for u in t2._architecture["utilities"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_architecture_summary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetArchitectureSummary:
|
|
def test_returns_formatted_string(self, tracker):
|
|
tracker.add_module("GraphEngine", "Runs the graph", "graph.py")
|
|
tracker.add_utility("parse_json", "Parses JSON input", "utils.py")
|
|
summary = tracker.get_architecture_summary()
|
|
assert isinstance(summary, str)
|
|
assert "Project Architecture Summary" in summary
|
|
assert "GraphEngine" in summary
|
|
assert "parse_json" in summary
|
|
|
|
def test_includes_tech_stack(self, tracker):
|
|
summary = tracker.get_architecture_summary()
|
|
assert "Tech Stack" in summary
|
|
assert "Python" in summary
|
|
|
|
def test_includes_naming_conventions(self, tracker):
|
|
summary = tracker.get_architecture_summary()
|
|
assert "Naming Conventions" in summary
|
|
assert "snake_case" in summary
|
|
|
|
def test_respects_max_tokens_limit(self, tracker):
|
|
# Add many modules to produce a large summary
|
|
for i in range(200):
|
|
tracker._architecture["modules"].append({
|
|
"name": f"Module_{i}",
|
|
"purpose": f"Purpose of module {i} with extra text for padding",
|
|
"file_path": f"src/module_{i}.py",
|
|
})
|
|
# Very small token limit
|
|
summary = tracker.get_architecture_summary(max_tokens=50)
|
|
# 50 tokens * 4 chars = 200 chars max
|
|
assert len(summary) <= 200
|
|
|
|
def test_empty_architecture_still_returns_summary(self, tracker):
|
|
summary = tracker.get_architecture_summary()
|
|
assert "Project Architecture Summary" in summary
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_architecture (async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUpdateArchitecture:
|
|
def test_basic_extraction_no_client(self, tracker, tmp_path):
|
|
# Create a sample Python file
|
|
py_file = tmp_path / "sample.py"
|
|
py_file.write_text(
|
|
'"""Sample module."""\n\n'
|
|
'class SampleClass:\n'
|
|
' """A sample class for testing."""\n'
|
|
' pass\n\n'
|
|
'def sample_function():\n'
|
|
' """A sample utility function."""\n'
|
|
' pass\n\n'
|
|
'def _private_function():\n'
|
|
' """Should be skipped."""\n'
|
|
' pass\n'
|
|
)
|
|
|
|
task = {"title": "Test task", "description": "Testing extraction"}
|
|
asyncio.run(tracker.update_architecture(task, [str(py_file)]))
|
|
|
|
modules = tracker._architecture["modules"]
|
|
utilities = tracker._architecture["utilities"]
|
|
assert any(m["name"] == "SampleClass" for m in modules)
|
|
assert any(u["name"] == "sample_function" for u in utilities)
|
|
# Private functions should be skipped
|
|
assert not any(u["name"] == "_private_function" for u in utilities)
|
|
|
|
def test_skips_non_python_files(self, tracker, tmp_path):
|
|
txt_file = tmp_path / "readme.txt"
|
|
txt_file.write_text("Not a Python file")
|
|
|
|
task = {"title": "Test task"}
|
|
asyncio.run(tracker.update_architecture(task, [str(txt_file)]))
|
|
assert tracker._architecture["modules"] == []
|
|
assert tracker._architecture["utilities"] == []
|
|
|
|
def test_skips_nonexistent_files(self, tracker):
|
|
task = {"title": "Test task"}
|
|
asyncio.run(tracker.update_architecture(task, ["/nonexistent/file.py"]))
|
|
assert tracker._architecture["modules"] == []
|
|
|
|
def test_ai_extraction_with_mock_client(self, tracker_with_client, tmp_path):
|
|
tracker, mock_client = tracker_with_client
|
|
|
|
py_file = tmp_path / "ai_sample.py"
|
|
py_file.write_text(
|
|
'class AIExtracted:\n'
|
|
' """Extracted by AI."""\n'
|
|
' pass\n'
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.text = json.dumps({
|
|
"classes": [{"name": "AIExtracted", "purpose": "AI detected class"}],
|
|
"functions": [{"name": "ai_helper", "description": "AI detected function"}],
|
|
})
|
|
mock_client.complete.return_value = mock_response
|
|
|
|
task = {"title": "AI test task"}
|
|
asyncio.run(tracker.update_architecture(task, [str(py_file)]))
|
|
|
|
modules = tracker._architecture["modules"]
|
|
utilities = tracker._architecture["utilities"]
|
|
assert any(m["name"] == "AIExtracted" for m in modules)
|
|
assert any(u["name"] == "ai_helper" for u in utilities)
|
|
|
|
def test_ai_extraction_fallback_on_failure(self, tracker_with_client, tmp_path):
|
|
tracker, mock_client = tracker_with_client
|
|
|
|
py_file = tmp_path / "fallback_sample.py"
|
|
py_file.write_text(
|
|
'class FallbackClass:\n'
|
|
' """Falls back to basic extraction."""\n'
|
|
' pass\n'
|
|
)
|
|
|
|
mock_client.complete.side_effect = Exception("API error")
|
|
|
|
task = {"title": "Fallback test"}
|
|
asyncio.run(tracker.update_architecture(task, [str(py_file)]))
|
|
|
|
# Should fall back to basic extraction
|
|
modules = tracker._architecture["modules"]
|
|
assert any(m["name"] == "FallbackClass" for m in modules)
|
|
|
|
def test_no_duplicate_modules(self, tracker, tmp_path):
|
|
py_file = tmp_path / "dup.py"
|
|
py_file.write_text('class DupClass:\n pass\n')
|
|
|
|
task = {"title": "Dup test"}
|
|
asyncio.run(tracker.update_architecture(task, [str(py_file)]))
|
|
asyncio.run(tracker.update_architecture(task, [str(py_file)]))
|
|
|
|
names = [m["name"] for m in tracker._architecture["modules"]]
|
|
assert names.count("DupClass") == 1
|