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