From 06261a80bef97ae5402da57cab8afe399f72cc38 Mon Sep 17 00:00:00 2001 From: deng Date: Sat, 29 Nov 2025 06:56:07 +0800 Subject: [PATCH] test gpx, weather, utils --- .pre-commit-config.yaml | 11 +- tests/__init__.py | 1 + tests/test_gpx.py | 268 ++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 62 ++++++++++ tests/test_weather.py | 262 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_gpx.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_weather.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ba3096..f7ff60b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,14 @@ repos: hooks: - id: ruff name: ruff - entry: ruff check . - language: python + entry: uv run ruff check . + language: system types: [python] + always_run: true + + - id: pytest + name: pytest + entry: uv run pytest tests/ -v + language: system + pass_filenames: false always_run: true \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ee8344c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# __init__.py for tests package diff --git a/tests/test_gpx.py b/tests/test_gpx.py new file mode 100644 index 0000000..252fdbb --- /dev/null +++ b/tests/test_gpx.py @@ -0,0 +1,268 @@ +# test_gpx.py +# +# author: deng +# date: 20251129 + +import io + +import pytest + + +class TestGPXProcessor: + """Test cases for GPXProcessor class.""" + + @pytest.fixture + def simple_gpx_data(self): + """Create a simple GPX file data for testing.""" + gpx_xml = """ + + + 100 + Test Waypoint + + + Test Track + + + 100 + + + + 150 + + + + 120 + + + + + """ + return io.BytesIO(gpx_xml.encode('utf-8')) + + @pytest.fixture + def gpx_processor(self, simple_gpx_data): + """Create a GPXProcessor instance with simple data.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(simple_gpx_data) + processor.validate_and_parse() + return processor + + def test_init(self): + """Test GPXProcessor initialization.""" + from hiking_assistant.gpx import GPXProcessor + + gpx_file = io.BytesIO(b'test data') + processor = GPXProcessor(gpx_file) + + assert processor.gpx_file == gpx_file + assert processor.gpx is None + assert processor.points == [] + assert processor.elevations == [] + assert processor.waypoints == [] + + def test_validate_and_parse_success(self, simple_gpx_data): + """Test successful GPX parsing.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(simple_gpx_data) + result = processor.validate_and_parse() + + assert result is True + assert len(processor.points) == 3 + assert len(processor.elevations) == 3 + assert len(processor.waypoints) == 1 + + def test_validate_and_parse_invalid_gpx_raises_exception(self): + """Test that invalid GPX data raises exception.""" + from hiking_assistant.gpx import GPXProcessor + + invalid_data = io.BytesIO(b'not a valid gpx file') + processor = GPXProcessor(invalid_data) + + with pytest.raises(Exception, match='GPX 檔案解析失敗'): + processor.validate_and_parse() + + def test_waypoint_extraction(self, gpx_processor): + """Test waypoint extraction from GPX.""" + assert len(gpx_processor.waypoints) == 1 + waypoint = gpx_processor.waypoints[0] + assert waypoint['lat'] == 25.0 + assert waypoint['lon'] == 121.0 + assert waypoint['name'] == 'Test Waypoint' + assert waypoint['elevation'] == 100.0 + + def test_calculate_distance(self, gpx_processor): + """Test distance calculation.""" + distance = gpx_processor.calculate_distance() + + assert distance > 0 + assert isinstance(distance, float) + assert len(gpx_processor.distances) == 3 + assert gpx_processor.distances[0] == 0.0 + + def test_calculate_distance_empty_points_returns_zero(self): + """Test distance calculation with no points.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(io.BytesIO(b'')) + distance = processor.calculate_distance() + + assert distance == 0.0 + + def test_calculate_elevation_gain_loss(self, gpx_processor): + """Test elevation gain/loss calculation.""" + gain, loss = gpx_processor.calculate_elevation_gain_loss() + + assert isinstance(gain, float) + assert isinstance(loss, float) + assert gain >= 0 + assert loss >= 0 + + def test_get_min_max_elevation(self, gpx_processor): + """Test min/max elevation retrieval.""" + min_elev, max_elev = gpx_processor.get_min_max_elevation() + + assert min_elev == 100.0 + assert max_elev == 150.0 + + def test_get_min_max_elevation_empty_returns_zero(self): + """Test min/max elevation with empty data.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(io.BytesIO(b'')) + min_elev, max_elev = processor.get_min_max_elevation() + + assert min_elev == 0.0 + assert max_elev == 0.0 + + def test_get_start_end_points(self, gpx_processor): + """Test start/end points retrieval.""" + start, end = gpx_processor.get_start_end_points() + + assert start == (25.0, 121.0, 100.0) + assert end == (25.002, 121.002, 120.0) + + def test_get_start_end_points_insufficient_data_returns_none(self): + """Test start/end points with insufficient data.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(io.BytesIO(b'')) + start, end = processor.get_start_end_points() + + assert start is None + assert end is None + + def test_get_all_points(self, gpx_processor): + """Test getting all route points.""" + points = gpx_processor.get_all_points() + + assert len(points) == 3 + assert all(isinstance(p, tuple) and len(p) == 2 for p in points) + + def test_get_elevation_profile_data(self, gpx_processor): + """Test elevation profile data retrieval.""" + # Need to calculate distances first + gpx_processor.calculate_distance() + distances, elevations = gpx_processor.get_elevation_profile_data() + + assert len(distances) == len(elevations) + assert len(distances) == 3 + + def test_get_gradients(self, gpx_processor): + """Test gradient calculation.""" + gradients = gpx_processor.get_gradients() + + assert len(gradients) == 2 # n-1 gradients for n points + assert all(isinstance(g, (int, float)) for g in gradients) + + def test_get_gradients_empty_returns_empty_list(self): + """Test gradient calculation with no points.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(io.BytesIO(b'')) + gradients = processor.get_gradients() + + assert gradients == [] + + def test_calculate_naismith_time(self, gpx_processor): + """Test Naismith's Rule time calculation.""" + time_minutes = gpx_processor.calculate_naismith_time() + + assert isinstance(time_minutes, int) + assert time_minutes >= 0 + + def test_calculate_naismith_time_with_custom_speeds(self, gpx_processor): + """Test Naismith calculation with custom speeds.""" + time_default = gpx_processor.calculate_naismith_time() + time_fast = gpx_processor.calculate_naismith_time(horizontal_speed=10, vertical_speed=1200) + + # Faster speeds should result in less time + assert time_fast < time_default + + def test_calculate_naismith_time_no_points_returns_zero(self): + """Test Naismith calculation with no points.""" + from hiking_assistant.gpx import GPXProcessor + + processor = GPXProcessor(io.BytesIO(b'')) + time_minutes = processor.calculate_naismith_time() + + assert time_minutes == 0 + + +class TestGPXProcessorEdgeCases: + """Test edge cases for GPXProcessor.""" + + def test_gpx_with_bytes_data(self): + """Test GPX parsing with bytes input.""" + from hiking_assistant.gpx import GPXProcessor + + gpx_xml = b""" + + + + 100 + + + """ + processor = GPXProcessor(io.BytesIO(gpx_xml)) + result = processor.validate_and_parse() + + assert result is True + + def test_gpx_with_string_data(self): + """Test GPX parsing with string input.""" + from hiking_assistant.gpx import GPXProcessor + + gpx_xml = """ + + + + 100 + + + """ + processor = GPXProcessor(io.StringIO(gpx_xml)) + result = processor.validate_and_parse() + + assert result is True + + def test_gpx_with_no_elevation_data(self): + """Test GPX parsing when elevation data is missing.""" + from hiking_assistant.gpx import GPXProcessor + + gpx_xml = """ + + + + + + + + """ + processor = GPXProcessor(io.BytesIO(gpx_xml.encode('utf-8'))) + processor.validate_and_parse() + + # Should default to 0 elevation + assert all(e == 0 for e in processor.elevations) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..17684ec --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,62 @@ +# test_utils.py +# +# author: deng +# date: 20251129 + +import base64 +import tempfile +from pathlib import Path + +import pytest + + +class TestConvertImageToBase64: + """Test cases for convert_image_to_base64 function.""" + + @pytest.fixture + def temp_image_file(self): + """Create a temporary image file for testing.""" + with tempfile.NamedTemporaryFile(mode='wb', suffix='.jpg', delete=False) as f: + # Create a simple test image (1x1 red pixel) + f.write(b'\xff\xd8\xff\xe0\x00\x10JFIF') # JPEG header + temp_path = f.name + yield temp_path + # Cleanup + Path(temp_path).unlink(missing_ok=True) + + def test_convert_image_to_base64_returns_string(self, temp_image_file): + """Test that function returns a string.""" + from hiking_assistant.utils import convert_image_to_base64 + + result = convert_image_to_base64(temp_image_file) + assert isinstance(result, str) + + def test_convert_image_to_base64_returns_valid_base64(self, temp_image_file): + """Test that returned string is valid base64.""" + from hiking_assistant.utils import convert_image_to_base64 + + result = convert_image_to_base64(temp_image_file) + # Try to decode - should not raise exception + decoded = base64.b64decode(result) + assert isinstance(decoded, bytes) + + def test_convert_image_to_base64_roundtrip(self, temp_image_file): + """Test roundtrip conversion (file -> base64 -> binary).""" + from hiking_assistant.utils import convert_image_to_base64 + + # Read original file + with open(temp_image_file, 'rb') as f: + original_data = f.read() + + # Convert to base64 and back + base64_str = convert_image_to_base64(temp_image_file) + decoded_data = base64.b64decode(base64_str) + + assert decoded_data == original_data + + def test_convert_image_to_base64_nonexistent_file_raises_error(self): + """Test that function raises error for non-existent file.""" + from hiking_assistant.utils import convert_image_to_base64 + + with pytest.raises(FileNotFoundError): + convert_image_to_base64('/nonexistent/path/to/image.jpg') diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..cbbba57 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,262 @@ +# test_weather.py +# +# author: deng +# date: 20251129 + +from unittest.mock import Mock, patch + +import pytest +import requests + + +class TestWeatherFetcher: + """Test cases for WeatherFetcher class.""" + + @pytest.fixture + def weather_fetcher(self): + """Create a WeatherFetcher instance.""" + from hiking_assistant.weather import WeatherFetcher + + return WeatherFetcher() + + @pytest.fixture + def mock_weather_response(self): + """Mock weather API response data.""" + return { + 'current': { + 'relative_humidity_2m': 75, + 'apparent_temperature': 18.5, + 'precipitation_probability': 30, + 'precipitation': 0.5, + 'wind_speed_10m': 15.2, + 'wind_direction_10m': 180, + }, + 'daily': { + 'temperature_2m_max': [22.0, 23.5, 21.0], + 'temperature_2m_min': [15.0, 16.0, 14.5], + 'apparent_temperature_max': [20.0, 21.5, 19.0], + 'apparent_temperature_min': [13.0, 14.0, 12.5], + 'relative_humidity_2m_mean': [70, 75, 68], + 'precipitation_sum': [0.0, 2.5, 0.0], + 'precipitation_probability_max': [20, 60, 15], + 'wind_speed_10m_max': [25.0, 35.0, 20.0], + 'wind_direction_10m_dominant': [180, 90, 270], + 'sunrise': ['2024-01-01T06:00:00', '2024-01-02T06:01:00', '2024-01-03T06:02:00'], + 'sunset': ['2024-01-01T18:00:00', '2024-01-02T18:01:00', '2024-01-03T18:02:00'], + 'uv_index_max': [5.0, 7.0, 3.0], + 'time': ['2024-01-01', '2024-01-02', '2024-01-03'], + }, + } + + def test_init_default_values(self): + """Test WeatherFetcher initialization with defaults.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher() + + assert fetcher.api_url == 'https://api.open-meteo.com/v1/forecast' + assert fetcher.request_timeout == 8 + assert fetcher.forecast_days == 7 + + def test_init_custom_values(self): + """Test WeatherFetcher initialization with custom values.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher(api_url='https://custom.api.com', request_timeout=10, forecast_days=3) + + assert fetcher.api_url == 'https://custom.api.com' + assert fetcher.request_timeout == 10 + assert fetcher.forecast_days == 3 + + def test_convert_wind_degrees_to_flow_direction_north(self, weather_fetcher): + """Test wind direction conversion for north.""" + result = weather_fetcher.convert_wind_degrees_to_flow_direction(0) + assert result in ['⬇️', '⬆️'] # 0 and 360 degrees + + def test_convert_wind_degrees_to_flow_direction_east(self, weather_fetcher): + """Test wind direction conversion for east.""" + result = weather_fetcher.convert_wind_degrees_to_flow_direction(90) + assert result == '⬅️' # 90 degrees = east wind blowing west + + def test_convert_wind_degrees_to_flow_direction_south(self, weather_fetcher): + """Test wind direction conversion for south.""" + result = weather_fetcher.convert_wind_degrees_to_flow_direction(180) + assert result == '⬆️' + + def test_convert_wind_degrees_to_flow_direction_west(self, weather_fetcher): + """Test wind direction conversion for west.""" + result = weather_fetcher.convert_wind_degrees_to_flow_direction(270) + assert result == '➡️' # 270 degrees = west wind blowing east + + def test_convert_wind_degrees_to_flow_direction_none(self, weather_fetcher): + """Test wind direction conversion with None input.""" + result = weather_fetcher.convert_wind_degrees_to_flow_direction(None) + assert result == 'N/A' + + def test_get_wind_speed_indicator_safe(self, weather_fetcher): + """Test wind speed indicator for safe level.""" + result = weather_fetcher.get_wind_speed_indicator(15) + assert result == '🟢' + + def test_get_wind_speed_indicator_caution(self, weather_fetcher): + """Test wind speed indicator for caution level.""" + result = weather_fetcher.get_wind_speed_indicator(30) + assert result == '🟡' + + def test_get_wind_speed_indicator_alert(self, weather_fetcher): + """Test wind speed indicator for alert level.""" + result = weather_fetcher.get_wind_speed_indicator(50) + assert result == '🟠' + + def test_get_wind_speed_indicator_dangerous(self, weather_fetcher): + """Test wind speed indicator for dangerous level.""" + result = weather_fetcher.get_wind_speed_indicator(65) + assert result == '🔴' + + def test_get_wind_speed_indicator_none(self, weather_fetcher): + """Test wind speed indicator with None input.""" + result = weather_fetcher.get_wind_speed_indicator(None) + assert result == '' + + def test_get_uv_index_indicator_low(self, weather_fetcher): + """Test UV index indicator for low level.""" + result = weather_fetcher.get_uv_index_indicator(2) + assert result == '🟢' + + def test_get_uv_index_indicator_moderate(self, weather_fetcher): + """Test UV index indicator for moderate level.""" + result = weather_fetcher.get_uv_index_indicator(4) + assert result == '🟡' + + def test_get_uv_index_indicator_high(self, weather_fetcher): + """Test UV index indicator for high level.""" + result = weather_fetcher.get_uv_index_indicator(6) + assert result == '🟠' + + def test_get_uv_index_indicator_very_high(self, weather_fetcher): + """Test UV index indicator for very high level.""" + result = weather_fetcher.get_uv_index_indicator(9) + assert result == '🔴' + + def test_get_uv_index_indicator_extreme(self, weather_fetcher): + """Test UV index indicator for extreme level.""" + result = weather_fetcher.get_uv_index_indicator(11) + assert result == '🟣' + + def test_get_uv_index_indicator_none(self, weather_fetcher): + """Test UV index indicator with None input.""" + result = weather_fetcher.get_uv_index_indicator(None) + assert result == '' + + @patch('hiking_assistant.weather.requests.get') + def test_get_weather_success(self, mock_get, weather_fetcher, mock_weather_response): + """Test successful weather data retrieval.""" + mock_response = Mock() + mock_response.json.return_value = mock_weather_response + mock_get.return_value = mock_response + + result = weather_fetcher.get_weather(25.0, 121.0) + + assert result is not None + assert 'current_humidity' in result + assert 'daily_temp_max' in result + assert result['current_humidity'] == 75 + assert len(result['daily_temp_max']) == 3 + + @patch('hiking_assistant.weather.requests.get') + def test_get_weather_request_exception(self, mock_get, weather_fetcher): + """Test weather retrieval with request exception.""" + mock_get.side_effect = requests.exceptions.RequestException('Connection error') + + result = weather_fetcher.get_weather(25.0, 121.0) + + assert result is None + + @patch('hiking_assistant.weather.requests.get') + def test_get_weather_json_parsing_error(self, mock_get, weather_fetcher): + """Test weather retrieval with JSON parsing error.""" + mock_response = Mock() + mock_response.json.side_effect = ValueError('Invalid JSON') + mock_get.return_value = mock_response + + result = weather_fetcher.get_weather(25.0, 121.0) + + assert result is None + + @patch('hiking_assistant.weather.requests.get') + def test_get_weather_api_parameters(self, mock_get, weather_fetcher, mock_weather_response): + """Test that correct API parameters are sent.""" + mock_response = Mock() + mock_response.json.return_value = mock_weather_response + mock_get.return_value = mock_response + + weather_fetcher.get_weather(25.123, 121.456) + + # Verify the call was made with correct parameters + mock_get.assert_called_once() + call_args = mock_get.call_args + params = call_args[1]['params'] + + assert params['latitude'] == 25.123 + assert params['longitude'] == 121.456 + assert params['timezone'] == 'auto' + assert params['forecast_days'] == 7 + + @patch('hiking_assistant.weather.requests.get') + def test_get_weather_timeout_parameter(self, mock_get, weather_fetcher, mock_weather_response): + """Test that timeout parameter is used.""" + mock_response = Mock() + mock_response.json.return_value = mock_weather_response + mock_get.return_value = mock_response + + weather_fetcher.get_weather(25.0, 121.0) + + # Verify timeout was passed + call_args = mock_get.call_args + assert call_args[1]['timeout'] == 8 + + +class TestWeatherFetcherEdgeCases: + """Test edge cases for WeatherFetcher.""" + + def test_forecast_days_minimum_is_one(self): + """Test that forecast_days is at least 1.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher(forecast_days=0) + assert fetcher.forecast_days == 1 + + fetcher = WeatherFetcher(forecast_days=-5) + assert fetcher.forecast_days == 1 + + def test_wind_direction_boundary_values(self): + """Test wind direction conversion at boundary values.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher() + + # Test 360 degrees (same as 0) + result_0 = fetcher.convert_wind_degrees_to_flow_direction(0) + result_360 = fetcher.convert_wind_degrees_to_flow_direction(360) + assert result_0 == result_360 + + def test_wind_speed_boundary_values(self): + """Test wind speed indicators at exact boundary values.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher() + + assert fetcher.get_wind_speed_indicator(20) == '🟡' # Exactly 20 + assert fetcher.get_wind_speed_indicator(40) == '🟠' # Exactly 40 + assert fetcher.get_wind_speed_indicator(60) == '🔴' # Exactly 60 + + def test_uv_index_boundary_values(self): + """Test UV index indicators at exact boundary values.""" + from hiking_assistant.weather import WeatherFetcher + + fetcher = WeatherFetcher() + + assert fetcher.get_uv_index_indicator(2) == '🟢' # Exactly 2 + assert fetcher.get_uv_index_indicator(5) == '🟡' # Exactly 5 + assert fetcher.get_uv_index_indicator(7) == '🟠' # Exactly 7 + assert fetcher.get_uv_index_indicator(10) == '🔴' # Exactly 10