first commit
This commit is contained in:
283
tests/test_architecture_tracker.py
Normal file
283
tests/test_architecture_tracker.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user