diff --git a/backend/tests/services/test_garmin_sync_functional.py b/backend/tests/services/test_garmin_sync_functional.py new file mode 100644 index 0000000..c2ebce8 --- /dev/null +++ b/backend/tests/services/test_garmin_sync_functional.py @@ -0,0 +1,278 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from backend.app.database import Base +from backend.app.services.workout_sync import WorkoutSyncService +from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError +from backend.app.models.workout import Workout +from backend.app.models.garmin_sync_log import GarminSyncLog +from datetime import datetime, timedelta +import os + +# --- Fixtures for Functional Testing --- + +@pytest.fixture(name="async_engine") +def async_engine_fixture(): + """Provides an asynchronous engine for an in-memory SQLite database.""" + return create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + +@pytest.fixture(name="async_session") +async def async_session_fixture(async_engine): + """Provides an asynchronous session for an in-memory SQLite database.""" + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + AsyncSessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=async_engine, class_=AsyncSession + ) + async with AsyncSessionLocal() as session: + yield session + + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture +def mock_garmin_service(): + """Mocks the GarminService for functional tests.""" + with patch('backend.app.services.workout_sync.GarminService', autospec=True) as MockGarminService: + mock_instance = MockGarminService.return_value + mock_instance.login = AsyncMock(return_value=True) + mock_instance.get_activities = AsyncMock(return_value=[]) + mock_instance.get_activity_details = AsyncMock(return_value={}) + yield mock_instance + +@pytest.fixture +def workout_sync_service(async_session: AsyncSession, mock_garmin_service: MagicMock) -> WorkoutSyncService: + """Provides a WorkoutSyncService instance with a correctly resolved async session.""" + import asyncio + session = asyncio.run(async_session.__anext__()) + service = WorkoutSyncService(db=session) + service.garmin_service = mock_garmin_service + return service + +# --- Test Cases --- + +@pytest.mark.asyncio +async def test_successful_sync_functional(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test successful synchronization of recent activities.""" + # Arrange + mock_garmin_service.get_activities.return_value = [ + { + 'activityId': '1001', + 'activityType': {'typeKey': 'cycling'}, + 'startTimeLocal': (datetime.now() - timedelta(days=1)).isoformat(), + 'duration': 3600, + 'distance': 50000, + 'averageHR': 150, + 'maxHR': 180, + 'avgPower': 200, + 'elevationGain': 500 + } + ] + mock_garmin_service.get_activity_details.return_value = { + 'avgPower': 200, + 'elevationGain': 500, + 'temperature': 25 + } + + # Act + synced_count = await workout_sync_service.sync_recent_activities(days_back=7) + + # Assert + assert synced_count == 1 + + # Verify workout in DB + workouts = (await async_session.execute(select(Workout))).scalars().all() + assert len(workouts) == 1 + assert workouts[0].garmin_activity_id == '1001' + assert workouts[0].activity_type == 'cycling' + assert workouts[0].avg_power == 200 + assert 'temperature' in workouts[0].metrics + + # Verify sync log in DB + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'success' + assert sync_logs[0].activities_synced == 1 + assert sync_logs[0].error_message is None + +@pytest.mark.asyncio +async def test_sync_with_no_new_activities(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test synchronization when no new activities are found.""" + # Arrange + mock_garmin_service.get_activities.return_value = [] # No activities + + # Act + synced_count = await workout_sync_service.sync_recent_activities(days_back=7) + + # Assert + assert synced_count == 0 + workouts = (await async_session.execute(select(Workout))).scalars().all() + assert len(workouts) == 0 + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'success' + assert sync_logs[0].activities_synced == 0 + +@pytest.mark.asyncio +async def test_sync_with_authentication_error(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test synchronization failure due to Garmin authentication error.""" + # Arrange + mock_garmin_service.get_activities.side_effect = GarminAuthError("Invalid credentials") + + # Act & Assert + with pytest.raises(GarminAuthError): + await workout_sync_service.sync_recent_activities(days_back=7) + + # Verify sync log in DB + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'auth_error' + assert "Invalid credentials" in sync_logs[0].error_message + +@pytest.mark.asyncio +async def test_sync_with_api_error(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test synchronization failure due to general Garmin API error.""" + # Arrange + mock_garmin_service.get_activities.side_effect = GarminAPIError("Garmin service unavailable") + + # Act & Assert + with pytest.raises(GarminAPIError): + await workout_sync_service.sync_recent_activities(days_back=7) + + # Verify sync log in DB + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'api_error' + assert "Garmin service unavailable" in sync_logs[0].error_message + +@pytest.mark.asyncio +async def test_sync_with_activity_details_retry_success(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test successful retry of activity details fetch after initial failure.""" + # Arrange + mock_garmin_service.get_activities.return_value = [ + { + 'activityId': '1002', + 'activityType': {'typeKey': 'running'}, + 'startTimeLocal': (datetime.now() - timedelta(days=2)).isoformat(), + 'duration': 3000, + 'distance': 10000 + } + ] + # First call to get_activity_details fails, second succeeds + mock_garmin_service.get_activity_details.side_effect = [ + GarminAPIError("Temporary network issue"), + {'averageHR': 160, 'maxHR': 190} + ] + + # Act + # Mock asyncio.sleep to avoid actual delays during tests + with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + synced_count = await workout_sync_service.sync_recent_activities(days_back=7) + mock_sleep.assert_awaited_with(1) # First retry delay + + # Assert + assert synced_count == 1 + workouts = (await async_session.execute(select(Workout))).scalars().all() + assert len(workouts) == 1 + assert workouts[0].garmin_activity_id == '1002' + assert workouts[0].avg_hr == 160 + assert mock_garmin_service.get_activity_details.call_count == 2 + + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'success' + +@pytest.mark.asyncio +async def test_sync_with_activity_details_retry_failure(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test activity details fetch eventually fails after multiple retries.""" + # Arrange + mock_garmin_service.get_activities.return_value = [ + { + 'activityId': '1003', + 'activityType': {'typeKey': 'swimming'}, + 'startTimeLocal': (datetime.now() - timedelta(days=3)).isoformat(), + 'duration': 2000, + 'distance': 2000 + } + ] + # All calls to get_activity_details fail + mock_garmin_service.get_activity_details.side_effect = [ + GarminAPIError("Service unavailable"), + GarminAPIError("Service unavailable"), + GarminAPIError("Service unavailable") + ] + + # Act & Assert + with pytest.raises(GarminAPIError), \ + patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + await workout_sync_service.sync_recent_activities(days_back=7) + assert mock_garmin_service.get_activity_details.call_count == 3 + mock_sleep.assert_awaited_with(4) # Last retry delay (2**(3-1)) + + # Verify sync log in DB + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 1 + assert sync_logs[0].status == 'api_error' + assert "Service unavailable" in sync_logs[0].error_message + + # No workout should be saved + workouts = (await async_session.execute(select(Workout))).scalars().all() + assert len(workouts) == 0 + +@pytest.mark.asyncio +async def test_sync_with_duplicate_activities_in_garmin_feed(workout_sync_service: WorkoutSyncService, async_session: AsyncSession, mock_garmin_service: MagicMock): + """Test handling of duplicate activities appearing in the Garmin feed.""" + # Arrange + # First sync: add activity 1004 + mock_garmin_service.get_activities.return_value = [ + { + 'activityId': '1004', + 'activityType': {'typeKey': 'cycling'}, + 'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(), + 'duration': 4000, + 'distance': 60000 + } + ] + mock_garmin_service.get_activity_details.return_value = {'averageHR': 140} + await workout_sync_service.sync_recent_activities(days_back=7) + + # Second sync: activity 1004 is present again, plus a new activity 1005 + mock_garmin_service.get_activities.return_value = [ + { + 'activityId': '1004', + 'activityType': {'typeKey': 'cycling'}, + 'startTimeLocal': (datetime.now() - timedelta(days=4)).isoformat(), + 'duration': 4000, + 'distance': 60000 + }, + { + 'activityId': '1005', + 'activityType': {'typeKey': 'running'}, + 'startTimeLocal': (datetime.now() - timedelta(days=5)).isoformat(), + 'duration': 2500, + 'distance': 5000 + } + ] + mock_garmin_service.get_activity_details.return_value = {'averageHR': 130} # for activity 1005 + + # Act + synced_count = await workout_sync_service.sync_recent_activities(days_back=7) + + # Assert + assert synced_count == 1 # Only 1005 should be synced + workouts = (await async_session.execute(select(Workout))).scalars().all() + assert len(workouts) == 2 + assert any(w.garmin_activity_id == '1004' for w in workouts) + assert any(w.garmin_activity_id == '1005' for w in workouts) + + sync_logs = (await async_session.execute(select(GarminSyncLog))).scalars().all() + assert len(sync_logs) == 2 + assert sync_logs[1].activities_synced == 1 # Second log should show 1 activity synced \ No newline at end of file diff --git a/main.py b/main.py index 4c3aede..46cfbbc 100644 --- a/main.py +++ b/main.py @@ -148,8 +148,14 @@ class CyclingCoachApp(App): async def on_mount(self) -> None: """Initialize the application when mounted.""" - # Set initial active navigation + sys.stdout.write("CyclingCoachApp.on_mount: START\n") + # Set initial active navigation and tab self.query_one("#nav-dashboard").add_class("-active") + tabs = self.query_one("#main-tabs", TabbedContent) + if tabs: + tabs.active = "dashboard-tab" + sys.stdout.write("CyclingCoachApp.on_mount: Activated dashboard-tab\n") + sys.stdout.write("CyclingCoachApp.on_mount: END\n") async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle navigation button presses.""" diff --git a/tui/services/workout_service.py b/tui/services/workout_service.py index b464ed8..8bf5d2e 100644 --- a/tui/services/workout_service.py +++ b/tui/services/workout_service.py @@ -147,4 +147,65 @@ class WorkoutService: } except Exception as e: - raise Exception(f"Error fetching workout {workout_id}: {str(e)}") \ No newline at end of file + raise Exception(f"Error fetching workout {workout_id}: {str(e)}") + + async def get_sync_status(self) -> Dict: + """Get the latest Garmin sync status.""" + sync_service = WorkoutSyncService(self.db) + latest_sync = await sync_service.get_latest_sync_status() + if not latest_sync: + return {"status": "not_available", "last_sync_time": None} + return { + "status": latest_sync.status, + "last_sync_time": latest_sync.last_sync_time.isoformat() if latest_sync.last_sync_time else None, + "activities_synced": latest_sync.activities_synced, + "error_message": latest_sync.error_message, + } + + async def get_workout_analyses(self, workout_id: int) -> List[Dict]: + """Get all analyses for a specific workout.""" + result = await self.db.execute( + select(Analysis).where(Analysis.workout_id == workout_id).order_by(desc(Analysis.created_at)) + ) + analyses = result.scalars().all() + return [ + { + "id": a.id, + "analysis_type": a.analysis_type, + "feedback": a.feedback, + "suggestions": a.suggestions, + "created_at": a.created_at.isoformat(), + "approved": a.approved, + } + for a in analyses + ] + + async def sync_garmin_activities(self, days_back: int = 7) -> Dict: + """Sync Garmin activities.""" + try: + sync_service = WorkoutSyncService(self.db) + synced_count = await sync_service.sync_recent_activities(days_back=days_back) + return {"status": "success", "activities_synced": synced_count} + except Exception as e: + return {"status": "error", "message": str(e)} + + async def analyze_workout(self, workout_id: int) -> Dict: + """Trigger AI analysis for a workout.""" + try: + ai_service = AIService(self.db) + analysis = await ai_service.analyze_workout(workout_id) + return {"status": "success", "message": f"Analysis created with ID: {analysis.id}"} + except Exception as e: + return {"status": "error", "message": str(e)} + + async def approve_analysis(self, analysis_id: int) -> Dict: + """Approve a workout analysis.""" + try: + analysis = await self.db.get(Analysis, analysis_id) + if not analysis: + raise Exception("Analysis not found") + analysis.approved = True + await self.db.commit() + return {"status": "success", "message": "Analysis approved."} + except Exception as e: + return {"status": "error", "message": str(e)} \ No newline at end of file diff --git a/tui/views/test_workourts_view.py b/tui/views/test_workourts_view.py new file mode 100644 index 0000000..136da86 --- /dev/null +++ b/tui/views/test_workourts_view.py @@ -0,0 +1,804 @@ +""" +Comprehensive pytest tests for WorkoutView TUI component. +Tests async data loading, service calls, and UI interactions. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime +from textual.app import App +from textual.widgets import DataTable, Static, Button, TabbedContent + +from tui.views.workouts import WorkoutView, WorkoutMetricsChart, WorkoutAnalysisPanel +from tui.services.workout_service import WorkoutService + + +# Mock data fixtures +@pytest.fixture +def mock_workouts(): + """Sample workout data for testing.""" + return [ + { + "id": 1, + "garmin_activity_id": "123456789", + "activity_type": "cycling", + "start_time": "2024-01-15T14:30:00Z", + "duration_seconds": 4500, + "distance_m": 32500, + "avg_hr": 145, + "max_hr": 165, + "avg_power": 180, + "max_power": 320, + "avg_cadence": 85, + "elevation_gain_m": 450 + }, + { + "id": 2, + "garmin_activity_id": "987654321", + "activity_type": "running", + "start_time": "2024-01-14T09:15:00Z", + "duration_seconds": 2700, + "distance_m": 8000, + "avg_hr": 155, + "max_hr": 175, + "avg_power": None, + "max_power": None, + "avg_cadence": 180, + "elevation_gain_m": 120 + } + ] + + +@pytest.fixture +def mock_sync_status(): + """Sample sync status data for testing.""" + return { + "status": "connected", + "last_sync_time": "2024-01-15T15:00:00Z", + "activities_synced": 25, + "error_message": None + } + + +@pytest.fixture +def mock_workout_analyses(): + """Sample workout analysis data for testing.""" + return [ + { + "id": 1, + "workout_id": 1, + "analysis_type": "performance", + "feedback": { + "effort_level": "moderate", + "pacing": "consistent", + "heart_rate_zones": "well distributed" + }, + "suggestions": { + "recovery": "Take an easy day tomorrow", + "training": "Focus on interval training next week" + }, + "approved": False, + "created_at": "2024-01-15T16:00:00Z" + } + ] + + +@pytest.fixture +def mock_workout_service(): + """Mock WorkoutService with all required methods.""" + service = AsyncMock(spec=WorkoutService) + service.get_workouts = AsyncMock() + service.get_sync_status = AsyncMock() + service.get_workout_analyses = AsyncMock() + service.sync_garmin_activities = AsyncMock() + service.analyze_workout = AsyncMock() + service.approve_analysis = AsyncMock() + return service + + +class TestWorkoutView: + """Test suite for WorkoutView component.""" + + @pytest.mark.asyncio + async def test_workout_view_initialization(self): + """Test WorkoutView initializes with correct default state.""" + async with App().run_test() as pilot: + view = WorkoutView() + await pilot.app.mount(view) + assert view.workouts == [] + assert view.selected_workout is None + assert view.workout_analyses == [] + assert view.loading is True + assert view.sync_status == {} + assert view.error_message is None + + @pytest.mark.asyncio + async def test_load_workouts_data_success(self, mock_workouts, mock_sync_status): + """Test successful loading of workouts data.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.get_workouts.return_value = mock_workouts + mock_service.get_sync_status.return_value = mock_sync_status + mock_service_class.return_value = mock_service + + # Call the method + result = await workout_view._load_workouts_data() + + # Verify results + workouts, sync_status = result + assert workouts == mock_workouts + assert sync_status == mock_sync_status + + # Verify service calls + mock_service.get_workouts.assert_called_once_with(limit=50) + mock_service.get_sync_status.assert_called_once() + + @pytest.mark.asyncio + async def test_load_workouts_data_database_error(self): + """Test handling of database errors during workout loading.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local: + # Setup mock to raise exception + mock_session_local.return_value.__aenter__.side_effect = Exception("Database connection failed") + + # Should raise the exception + with pytest.raises(Exception) as exc_info: + await workout_view._load_workouts_data() + + assert "Database connection failed" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_load_workouts_with_timeout(self, mock_workouts, mock_sync_status): + """Test workout loading with timeout functionality.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + # Mock the actual loading method to return quickly + with patch.object(workout_view, '_load_workouts_data') as mock_load: + mock_load.return_value = (mock_workouts, mock_sync_status) + + # Should complete successfully within timeout + result = await workout_view._load_workouts_with_timeout() + workouts, sync_status = result + + assert workouts == mock_workouts + assert sync_status == mock_sync_status + + @pytest.mark.asyncio + async def test_load_workouts_timeout_error(self): + """Test timeout handling during workout loading.""" + import asyncio + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + # Mock the actual loading method to hang + async def slow_load(): + await asyncio.sleep(0.1) # Longer than timeout + return [], {} + + workout_view.LOAD_TIMEOUT = 0.01 + + with patch.object(workout_view, '_load_workouts_data', side_effect=slow_load): + # Should raise timeout exception + with pytest.raises(Exception) as exc_info: + await workout_view._load_workouts_with_timeout() + + assert "timed out" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_on_workouts_loaded_success(self, mock_workouts, mock_sync_status): + """Test successful handling of loaded workout data.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.loading = True + workout_view.error_message = "Previous error" + + # Mock the UI update methods + with patch.object(workout_view, 'refresh') as mock_refresh, \ + patch.object(workout_view, 'populate_workouts_table') as mock_populate, \ + patch.object(workout_view, 'update_sync_status') as mock_update_sync: + + # Call the method + workout_view.on_workouts_loaded((mock_workouts, mock_sync_status)) + + # Verify state updates + assert workout_view.workouts == mock_workouts + assert workout_view.sync_status == mock_sync_status + assert workout_view.loading is False + assert workout_view.error_message is None + + # Verify UI method calls + mock_refresh.assert_called_once_with(layout=True) + mock_populate.assert_called_once() + mock_update_sync.assert_called_once() + + @pytest.mark.asyncio + async def test_on_workouts_loaded_error_handling(self): + """Test error handling in on_workouts_loaded.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.loading = True + + # Mock refresh to raise exception + with patch.object(workout_view, 'refresh', side_effect=Exception("UI Error")): + + # Should handle the exception gracefully + workout_view.on_workouts_loaded(([], {})) + + # Should still update loading state and set error + assert workout_view.loading is False + assert "Failed to process loaded data" in str(workout_view.error_message) + + @pytest.mark.asyncio + async def test_populate_workouts_table(self, mock_workouts): + """Test populating the workouts table with data.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.workouts = mock_workouts + + # Mock the DataTable widget + mock_table = MagicMock(spec=DataTable) + + with patch.object(workout_view, 'query_one', return_value=mock_table): + await workout_view.populate_workouts_table() + + # Verify table was cleared and populated + mock_table.clear.assert_called_once() + assert mock_table.add_row.call_count == len(mock_workouts) + + # Check first workout data formatting + first_call_args = mock_table.add_row.call_args_list[0][0] + assert "01/15 14:30" in first_call_args[0] + assert "cycling" in first_call_args[1] + assert "75min" in first_call_args[2] + assert "32.5km" in first_call_args[3] + assert "145 BPM" in first_call_args[4] + assert "180 W" in first_call_args[5] + + @pytest.mark.asyncio + async def test_update_sync_status(self, mock_sync_status): + """Test updating sync status display.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.sync_status = mock_sync_status + + # Mock the Status widget + mock_status_text = MagicMock(spec=Static) + + with patch.object(workout_view, 'query_one', return_value=mock_status_text): + await workout_view.update_sync_status() + + # Verify status text was updated + mock_status_text.update.assert_called_once() + update_text = mock_status_text.update.call_args[0][0] + + assert "Connected" in update_text + assert "2024-01-15 15:00" in update_text + assert "25" in update_text + + @pytest.mark.asyncio + async def test_sync_garmin_activities_success(self): + """Test successful Garmin sync operation.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.sync_garmin_activities.return_value = { + "status": "success", + "activities_synced": 5, + "message": "Sync completed" + } + mock_service_class.return_value = mock_service + + # Mock the refresh methods + with patch.object(workout_view, 'check_sync_status') as mock_check_sync, \ + patch.object(workout_view, 'refresh_workouts') as mock_refresh_workouts: + + await workout_view.sync_garmin_activities() + + # Verify service call + mock_service.sync_garmin_activities.assert_called_once_with(days_back=14) + + # Verify UI refresh calls + mock_check_sync.assert_called_once() + mock_refresh_workouts.assert_called_once() + + @pytest.mark.asyncio + async def test_sync_garmin_activities_failure(self): + """Test handling of Garmin sync failure.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.sync_garmin_activities.return_value = { + "status": "error", + "activities_synced": 0, + "message": "Authentication failed" + } + mock_service_class.return_value = mock_service + + # Mock the refresh methods + with patch.object(workout_view, 'check_sync_status') as mock_check_sync, \ + patch.object(workout_view, 'refresh_workouts') as mock_refresh_workouts: + + await workout_view.sync_garmin_activities() + + # Should still call refresh methods even on failure + mock_check_sync.assert_called_once() + mock_refresh_workouts.assert_called_once() + + @pytest.mark.asyncio + async def test_analyze_selected_workout_success(self, mock_workouts, mock_workout_analyses): + """Test successful workout analysis.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.selected_workout = mock_workouts[0] + + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.analyze_workout.return_value = { + "status": "success", + "message": "Analysis completed" + } + mock_service.get_workout_analyses.return_value = mock_workout_analyses + mock_service_class.return_value = mock_service + + # Mock refresh and message posting + with patch.object(workout_view, 'refresh') as mock_refresh, \ + patch.object(workout_view, 'post_message') as mock_post_message: + + await workout_view.analyze_selected_workout() + + # Verify service calls + mock_service.analyze_workout.assert_called_once_with(1) # workout ID + mock_service.get_workout_analyses.assert_called_once_with(1) + + # Verify UI updates + assert workout_view.workout_analyses == mock_workout_analyses + mock_refresh.assert_called_once() + + # Verify message posting + assert mock_post_message.called + message = mock_post_message.call_args[0][0] + assert hasattr(message, 'workout_id') + assert message.workout_id == 1 + + @pytest.mark.asyncio + async def test_analyze_selected_workout_no_selection(self): + """Test workout analysis when no workout is selected.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.selected_workout = None + + # Should not raise exception, just log warning + await workout_view.analyze_selected_workout() + + # No service calls should be made + # (This would be verified by not mocking any services) + + @pytest.mark.asyncio + async def test_approve_analysis_success(self, mock_workouts): + """Test successful analysis approval.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.selected_workout = mock_workouts[0] + + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.approve_analysis.return_value = { + "status": "success", + "message": "Analysis approved" + } + mock_service.get_workout_analyses.return_value = [] + mock_service_class.return_value = mock_service + + # Mock refresh + with patch.object(workout_view, 'refresh') as mock_refresh: + + await workout_view.approve_analysis(1) + + # Verify service calls + mock_service.approve_analysis.assert_called_once_with(1) + mock_service.get_workout_analyses.assert_called_once_with(1) + + # Verify UI refresh + mock_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_show_workout_details(self, mock_workouts, mock_workout_analyses): + """Test showing workout details view.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.get_workout_analyses.return_value = mock_workout_analyses + mock_service_class.return_value = mock_service + + # Mock TabbedContent widget + mock_tabs = MagicMock(spec=TabbedContent) + + with patch.object(workout_view, 'refresh') as mock_refresh, \ + patch.object(workout_view, 'query_one', return_value=mock_tabs), \ + patch.object(workout_view, 'post_message') as mock_post_message: + + await workout_view.show_workout_details(mock_workouts[0]) + + # Verify state updates + assert workout_view.selected_workout == mock_workouts[0] + assert workout_view.workout_analyses == mock_workout_analyses + + # Verify service call + mock_service.get_workout_analyses.assert_called_once_with(1) + + # Verify UI updates + mock_refresh.assert_called_once() + assert mock_tabs.active == "workout-details-tab" + + # Verify message posting + mock_post_message.assert_called_once() + + @pytest.mark.asyncio + async def test_watch_loading_state_change(self): + """Test reactive response to loading state changes.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view._mounted = True + + with patch.object(workout_view, 'refresh') as mock_refresh: + # Trigger loading state change + workout_view.loading = False + workout_view.watch_loading(False) + + # Should trigger refresh + mock_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_watch_error_message_change(self): + """Test reactive response to error message changes.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view._mounted = True + + with patch.object(workout_view, 'refresh') as mock_refresh: + # Trigger error message change + error_msg = "Test error" + workout_view.error_message = error_msg + workout_view.watch_error_message(error_msg) + + # Should trigger refresh + mock_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_button_pressed_refresh_workouts(self): + """Test refresh workouts button press.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + # Mock button and refresh method + mock_button = MagicMock() + mock_button.id = "refresh-workouts-btn" + + with patch.object(workout_view, 'refresh_workouts') as mock_refresh: + event = Button.Pressed(mock_button) + await workout_view.on_button_pressed(event) + + mock_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_button_pressed_sync_garmin(self): + """Test sync Garmin button press.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + # Mock button and sync method + mock_button = MagicMock() + mock_button.id = "sync-garmin-btn" + + with patch.object(workout_view, 'sync_garmin_activities') as mock_sync: + event = Button.Pressed(mock_button) + await workout_view.on_button_pressed(event) + + mock_sync.assert_called_once() + + @pytest.mark.asyncio + async def test_button_pressed_retry_loading(self): + """Test retry loading button press.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.error_message = "Previous error" + + # Mock button and load_data method + mock_button = MagicMock() + mock_button.id = "retry-loading-btn" + + with patch.object(workout_view, 'load_data') as mock_load_data: + event = Button.Pressed(mock_button) + await workout_view.on_button_pressed(event) + + # Should clear error and reload + assert workout_view.error_message is None + mock_load_data.assert_called_once() + + @pytest.mark.asyncio + async def test_data_table_row_selection(self, mock_workouts): + """Test row selection in workouts table.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + workout_view.workouts = mock_workouts + + # Mock table and event + mock_table = MagicMock(spec=DataTable) + mock_table.id = "workouts-table" + + # Mock event with row selection + event = MagicMock() + event.data_table = mock_table + event.cursor_row = 0 + event.row_key = MagicMock() + event.row_key.value = 0 + + with patch.object(workout_view, 'show_workout_details') as mock_show_details: + await workout_view.on_data_table_row_selected(event) + + # Should show details for first workout + mock_show_details.assert_called_once_with(mock_workouts[0]) + + @pytest.mark.asyncio + async def test_integration_full_workflow(self, mock_workouts, mock_sync_status, mock_workout_analyses): + """Test complete workflow integration.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + with patch('tui.views.workouts.AsyncSessionLocal') as mock_session_local, \ + patch('tui.views.workouts.WorkoutService') as mock_service_class: + + # Setup mocks + mock_db = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_db + + mock_service = AsyncMock() + mock_service.get_workouts.return_value = mock_workouts + mock_service.get_sync_status.return_value = mock_sync_status + mock_service.get_workout_analyses.return_value = mock_workout_analyses + mock_service.analyze_workout.return_value = { + "status": "success", + "message": "Analysis completed" + } + mock_service_class.return_value = mock_service + + # Mock UI methods + with patch.object(workout_view, 'refresh'), \ + patch.object(workout_view, 'populate_workouts_table'), \ + patch.object(workout_view, 'update_sync_status'), \ + patch.object(workout_view, 'query_one'), \ + patch.object(workout_view, 'post_message'): + + # 1. Load initial data + result = await workout_view._load_workouts_data() + workouts, sync_status = result + workout_view.on_workouts_loaded((workouts, sync_status)) + + # 2. Show workout details + await workout_view.show_workout_details(workouts[0]) + + # 3. Analyze workout + await workout_view.analyze_selected_workout() + + # Verify full workflow executed + assert workout_view.workouts == mock_workouts + assert workout_view.sync_status == mock_sync_status + assert workout_view.selected_workout == mock_workouts[0] + assert workout_view.workout_analyses == mock_workout_analyses + assert workout_view.loading is False + assert workout_view.error_message is None + + @pytest.mark.asyncio + async def test_compose_with_error(self): + """Test the compose method when an error message is set.""" + app = App() + workout_view = WorkoutView() + app.push_view(workout_view) + async with app.run_test(): + workout_view.error_message = "A critical error occurred" + workout_view.loading = False + + # Since compose is called on render, we can't call it directly. + # We'll check the widgets that get created. + widgets = list(workout_view.compose()) + + # Check for error display and retry button + assert any(isinstance(w, Static) and "A critical error occurred" in str(w.render()) for w in widgets) + assert any(isinstance(w, Button) and w.id == "retry-loading-btn" for w in widgets) + # Ensure loading spinner is not present + assert not any(isinstance(w, LoadingSpinner) for w in widgets) + + @pytest.mark.asyncio + async def test_populate_workouts_table_with_malformed_data(self): + """Test populating the table with malformed or missing data.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + malformed_workouts = [ + { + "id": 1, + "start_time": "Invalid-Date", + "duration_seconds": None, + "distance_m": "Not a number", + "avg_hr": None, + "avg_power": None + } + ] + workout_view.workouts = malformed_workouts + + mock_table = MagicMock(spec=DataTable) + + with patch.object(workout_view, 'query_one', return_value=mock_table): + await workout_view.populate_workouts_table() + + mock_table.clear.assert_called_once() + assert mock_table.add_row.call_count == 1 + + # Check that it fell back to default/graceful values + call_args = mock_table.add_row.call_args[0] + assert "Invalid-Date" in call_args[0] # Date fallback + assert "Unknown" in call_args[1] # Activity type fallback + assert "N/A" in call_args[2] # Duration fallback + assert "N/A" in call_args[3] # Distance fallback + + @pytest.mark.asyncio + async def test_button_pressed_check_sync(self): + """Test check sync status button press.""" + async with App().run_test() as pilot: + workout_view = WorkoutView() + await pilot.app.mount(workout_view) + async with workout_view.run_test(): + mock_button = MagicMock() + mock_button.id = "check-sync-btn" + + with patch.object(workout_view, 'check_sync_status') as mock_check_sync: + event = Button.Pressed(mock_button) + await workout_view.on_button_pressed(event) + + mock_check_sync.assert_called_once() + +class TestWorkoutMetricsChart: + """Test suite for the WorkoutMetricsChart widget.""" + + def test_chart_creation_with_data(self): + """Test ASCII chart generation with valid data.""" + metrics_data = [ + {"heart_rate": 150, "power": 200, "speed": 30}, + {"heart_rate": 160, "power": 220, "speed": 32}, + ] + chart = WorkoutMetricsChart(metrics_data) + + # Simple check to ensure it produces a Static widget with content + static_widget = chart.create_ascii_chart("Test", [10, 20]) + assert isinstance(static_widget, Static) + assert "Min: 10.0" in str(static_widget.render()) + + def test_chart_creation_no_data(self): + """Test ASCII chart generation with no data.""" + chart = WorkoutMetricsChart([]) + static_widget = chart.create_ascii_chart("Test", []) + assert "No data" in str(static_widget.render()) + +class TestWorkoutAnalysisPanel: + """Test suite for the WorkoutAnalysisPanel widget.""" + + def test_format_feedback(self): + """Test the formatting of feedback data.""" + panel = WorkoutAnalysisPanel(workout_data={}, analyses=[]) + feedback_dict = {"effort_level": "high", "pacing": "good"} + formatted = panel.format_feedback(feedback_dict) + assert "Effort Level: high" in formatted + assert "Pacing: good" in formatted + + def test_format_suggestions(self): + """Test the formatting of suggestions data.""" + panel = WorkoutAnalysisPanel(workout_data={}, analyses=[]) + suggestions_dict = {"next_workout": "easy spin", "focus_on": "cadence"} + formatted = panel.format_suggestions(suggestions_dict) + assert "• Next Workout: easy spin" in formatted + assert "• Focus On: cadence" in formatted + + @pytest.mark.asyncio + async def test_compose_with_analysis(self, mock_workout_analyses): + """Test panel composition with existing analysis.""" + async with App().run_test() as pilot: + panel = WorkoutAnalysisPanel(workout_data={}, analyses=mock_workout_analyses) + await pilot.app.mount(panel) + async with panel.run_test(): + # Check that it creates a Collapsible widget when analysis is present + assert panel.query_one(Collapsible) + + @pytest.mark.asyncio + async def test_compose_no_analysis(self): + """Test panel composition without any analysis.""" + async with App().run_test() as pilot: + panel = WorkoutAnalysisPanel(workout_data={}, analyses=[]) + await pilot.app.mount(panel) + async with panel.run_test(): + # Check that it shows a "No analysis" message and an "Analyze" button + assert "No analysis available" in panel.query_one(Static).render() + assert panel.query_one("#analyze-workout-btn", Button) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tui/views/workouts.py b/tui/views/workouts.py index 99d29bb..4312cbd 100644 --- a/tui/views/workouts.py +++ b/tui/views/workouts.py @@ -217,6 +217,7 @@ class WorkoutView(BaseView): def compose(self) -> ComposeResult: """Create workout view layout.""" + sys.stdout.write("WorkoutView.__init__: Instantiated\n") sys.stdout.write("WorkoutView.compose: START\n") yield Static("Workout Management", classes="view-title") @@ -245,6 +246,7 @@ class WorkoutView(BaseView): """Load workout data when mounted.""" sys.stdout.write("WorkoutView.on_mount: START\n") self.loading = True + sys.stdout.write("WorkoutView.on_mount: Calling load_data\n") self.load_data() sys.stdout.write("WorkoutView.on_mount: END\n") @@ -266,13 +268,14 @@ class WorkoutView(BaseView): """Public method to trigger data loading for the workout view.""" sys.stdout.write("WorkoutView.load_data: START\n") self.loading = True + sys.stdout.write("WorkoutView.load_data: Before run_async\n") self.run_async( self._async_wrapper( self._load_workouts_with_timeout(), self.on_workouts_loaded ) ) - sys.stdout.write("WorkoutView.load_data: END\n") + sys.stdout.write("WorkoutView.load_data: After run_async - END\n") async def _load_workouts_data(self) -> tuple[list, dict]: """Load workouts and sync status (async worker).""" @@ -285,8 +288,9 @@ class WorkoutView(BaseView): sys.stdout.write("WorkoutView._load_workouts_data: Before get_workouts\n") workouts = await workout_service.get_workouts(limit=50) sys.stdout.write("WorkoutView._load_workouts_data: After get_workouts\n") + sys.stdout.write("WorkoutView._load_workouts_data: Before await get_sync_status\n") sync_status = await workout_service.get_sync_status() - sys.stdout.write("WorkoutView._load_workouts_data: After get_sync_status\n") + sys.stdout.write("WorkoutView._load_workouts_data: After await get_sync_status\n") self.log(f"Workouts data loaded: {len(workouts)} workouts, sync status: {sync_status}") sys.stdout.write("WorkoutView._load_workouts_data: Before return\n") return workouts, sync_status