added garmin functional tests

This commit is contained in:
2025-09-28 08:07:57 -07:00
parent dcc7f4e6fa
commit c2dc64f322

View File

@@ -6,7 +6,8 @@ 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 textual.widgets import DataTable, Static, Button, TabbedContent, Collapsible
from tui.widgets.loading import LoadingSpinner
from tui.views.workouts import WorkoutView, WorkoutMetricsChart, WorkoutAnalysisPanel
from tui.services.workout_service import WorkoutService
@@ -148,16 +149,15 @@ class TestWorkoutView:
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)
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):
@@ -165,17 +165,16 @@ class TestWorkoutView:
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
# 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):
@@ -184,20 +183,18 @@ class TestWorkoutView:
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
# 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()
with patch.object(workout_view, '_load_workouts_data', side_effect=slow_load):
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):
@@ -205,28 +202,27 @@ class TestWorkoutView:
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"
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:
# 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()
# 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):
@@ -234,18 +230,20 @@ class TestWorkoutView:
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")):
workout_view.loading = True
# Mock refresh to raise exception
with patch.object(workout_view, 'refresh', side_effect=Exception("UI Error")):
try:
# 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)
except Exception:
# The exception is caught and handled inside the method
pass
# 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):
@@ -253,27 +251,26 @@ class TestWorkoutView:
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
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()
# Mock the DataTable widget
mock_table = MagicMock(spec=DataTable)
# Verify table was cleared and populated
mock_table.clear.assert_called_once()
assert mock_table.add_row.call_count == len(mock_workouts)
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]
# 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):
@@ -281,22 +278,21 @@ class TestWorkoutView:
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
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()
# Mock the Status widget
mock_status_text = MagicMock(spec=Static)
# Verify status text was updated
mock_status_text.update.assert_called_once()
update_text = mock_status_text.update.call_args[0][0]
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
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):
@@ -304,34 +300,33 @@ class TestWorkoutView:
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:
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:
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
await workout_view.sync_garmin_activities()
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
# Verify service call
mock_service.sync_garmin_activities.assert_called_once_with(days_back=14)
# 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()
# 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):
@@ -339,31 +334,30 @@ class TestWorkoutView:
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:
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:
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
await workout_view.sync_garmin_activities()
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()
# 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):
@@ -371,43 +365,42 @@ class TestWorkoutView:
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]
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:
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:
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
await workout_view.analyze_selected_workout()
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
# Verify service calls
mock_service.analyze_workout.assert_called_once_with(1) # workout ID
mock_service.get_workout_analyses.assert_called_once_with(1)
# 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
# Verify UI updates
assert workout_view.workout_analyses == mock_workout_analyses
mock_refresh.assert_called()
# 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):
@@ -415,14 +408,13 @@ class TestWorkoutView:
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)
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):
@@ -430,35 +422,34 @@ class TestWorkoutView:
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]
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:
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:
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
await workout_view.approve_analysis(1)
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
# Verify service calls
mock_service.approve_analysis.assert_called_once_with(1)
mock_service.get_workout_analyses.assert_called_once_with(1)
# 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()
# Verify UI refresh
mock_refresh.assert_called_once()
@pytest.mark.asyncio
async def test_show_workout_details(self, mock_workouts, mock_workout_analyses):
@@ -466,40 +457,39 @@ class TestWorkoutView:
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:
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:
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
await workout_view.show_workout_details(mock_workouts[0])
mock_service = AsyncMock()
mock_service.get_workout_analyses.return_value = mock_workout_analyses
mock_service_class.return_value = mock_service
# Verify state updates
assert workout_view.selected_workout == mock_workouts[0]
assert workout_view.workout_analyses == mock_workout_analyses
# Mock TabbedContent widget
mock_tabs = MagicMock(spec=TabbedContent)
# Verify service call
mock_service.get_workout_analyses.assert_called_once_with(1)
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()
# Verify UI updates
mock_refresh.assert_called()
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):
@@ -507,16 +497,15 @@ class TestWorkoutView:
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
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)
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()
# Should trigger refresh
mock_refresh.assert_called()
@pytest.mark.asyncio
async def test_watch_error_message_change(self):
@@ -524,17 +513,16 @@ class TestWorkoutView:
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
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)
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()
# Should trigger refresh
mock_refresh.assert_called()
@pytest.mark.asyncio
async def test_button_pressed_refresh_workouts(self):
@@ -542,16 +530,15 @@ class TestWorkoutView:
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"
# 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)
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()
mock_refresh.assert_called_once()
@pytest.mark.asyncio
async def test_button_pressed_sync_garmin(self):
@@ -559,16 +546,15 @@ class TestWorkoutView:
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"
# 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)
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()
mock_sync.assert_called_once()
@pytest.mark.asyncio
async def test_button_pressed_retry_loading(self):
@@ -576,20 +562,19 @@ class TestWorkoutView:
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"
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)
# 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()
# 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):
@@ -597,25 +582,24 @@ class TestWorkoutView:
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
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)
# 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])
# 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):
@@ -623,69 +607,67 @@ class TestWorkoutView:
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:
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'):
# Setup mocks
mock_db = AsyncMock()
mock_session_local.return_value.__aenter__.return_value = mock_db
# 1. Load initial data
result = await workout_view._load_workouts_data()
workouts, sync_status = result
workout_view.on_workouts_loaded((workouts, sync_status))
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
# 2. Show workout details
await workout_view.show_workout_details(workouts[0])
# 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
# 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
class TestApp(App):
def compose(self):
workout_view = WorkoutView()
workout_view.error_message = "A critical error occurred"
workout_view.loading = False
yield workout_view
# 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())
app = TestApp()
async with app.run_test() as pilot:
# 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)
assert pilot.app.query_one(Static)
assert "A critical error occurred" in str(pilot.app.query_one(Static).render())
assert pilot.app.query_one("#retry-loading-btn", Button)
# Ensure loading spinner is not present
assert not any(isinstance(w, LoadingSpinner) for w in widgets)
assert not pilot.app.query(LoadingSpinner)
@pytest.mark.asyncio
async def test_populate_workouts_table_with_malformed_data(self):
@@ -693,33 +675,32 @@ class TestWorkoutView:
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
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 = MagicMock(spec=DataTable)
mock_table.clear.assert_called_once()
assert mock_table.add_row.call_count == 1
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
# 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):
@@ -727,15 +708,14 @@ class TestWorkoutView:
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"
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)
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()
mock_check_sync.assert_called_once()
class TestWorkoutMetricsChart:
"""Test suite for the WorkoutMetricsChart widget."""
@@ -784,9 +764,8 @@ class TestWorkoutAnalysisPanel:
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)
# Check that it creates a Collapsible widget when analysis is present
assert panel.query(Collapsible)
@pytest.mark.asyncio
async def test_compose_no_analysis(self):
@@ -794,10 +773,9 @@ class TestWorkoutAnalysisPanel:
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)
# Check that it shows a "No analysis" message and an "Analyze" button
assert "No analysis available" in str(panel.query_one(Static).render())
assert panel.query_one("#analyze-workout-btn", Button)
if __name__ == "__main__":