sync - still working on the TUI

This commit is contained in:
2025-09-28 05:51:12 -07:00
parent ec02b923af
commit 88fb6a601a
5 changed files with 1157 additions and 4 deletions

View File

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

View File

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

View File

@@ -147,4 +147,65 @@ class WorkoutService:
}
except Exception as e:
raise Exception(f"Error fetching workout {workout_id}: {str(e)}")
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)}

View File

@@ -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"])

View File

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