import os import tempfile from datetime import timedelta import pytest from django.contrib.auth import get_user_model from django.core.files import File from locations.models import GeoLocation from scrobbles.importers.trail_gpx import ( compute_trail_stats, find_route_waypoint, import_trail_gpx, parse_trackpoints, ) from scrobbles.models import Scrobble, TrailGPXImport from trails.models import Trail, TrailLogData User = get_user_model() SAMPLE_GPX = os.path.join( os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx" ) @pytest.fixture def user(db): return User.objects.create(email="trailblazer@example.com") @pytest.fixture def sample_gpx_path(): return SAMPLE_GPX class TestParseTrackpoints: def test_parses_gpx(self, sample_gpx_path): result = parse_trackpoints(sample_gpx_path) points = result["points"] assert len(points) == 837 assert result["name"] == "Morning Run ⛅" assert result["description"] == "Run" lat, lon, ele, t = points[0] assert round(lat, 6) == 34.190598 assert round(lon, 6) == -118.844015 assert ele == 305.3 assert t is not None def test_first_and_last_times(self, sample_gpx_path): result = parse_trackpoints(sample_gpx_path) points = result["points"] first_time = points[0][3] last_time = points[-1][3] duration = (last_time - first_time).total_seconds() assert duration == pytest.approx(3770, abs=5) def test_gpx_extra_metadata(self, sample_gpx_path): result = parse_trackpoints(sample_gpx_path) extra = result["extra"] assert extra["avg_heartrate"] == 159 assert extra["max_heartrate"] == 183 assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1) assert extra["activity_type"] == "Run" assert extra["moving_time_seconds"] == 3008 assert extra["total_elevation_gain_m"] == 246.4 class TestImportTrailGPX: def test_creates_trail(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) assert Trail.objects.filter(title="Morning Run ⛅").exists() def test_creates_geolocation(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists() def test_sets_trailhead(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) trail = Trail.objects.filter(title="Morning Run ⛅").first() assert trail.trailhead_location is not None assert round(trail.trailhead_location.lat, 6) == 34.190598 def test_creates_scrobble(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) assert Scrobble.objects.filter(source="GPX Import").count() == 1 def test_scrobble_timestamps(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09") assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59") assert scrobble.media_type == Scrobble.MediaType.TRAIL def test_scrobble_has_trail_fk(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.trail is not None assert scrobble.trail.title == "Morning Run ⛅" def test_scrobble_has_gpx_file(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.gpx_file assert scrobble.gpx_file.name.endswith(".gpx") def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo) import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.trail.id == trail.id def test_dedup(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) import_trail_gpx(sample_gpx_path, user.id) assert Scrobble.objects.filter(source="GPX Import").count() == 1 def test_scrobble_log_has_stats(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() log = scrobble.log assert log["distance_km"] == pytest.approx(8.2, abs=0.2) assert log["elevation_gain_m"] == pytest.approx(260, abs=20) assert log["moving_time_seconds"] == pytest.approx(3770, abs=10) assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5) assert log["description"] == "Run" def test_scrobble_playback_position(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5) def test_scrobble_has_timezone(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.timezone is not None assert isinstance(scrobble.timezone, str) def test_scrobble_log_extra_metadata(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() log = scrobble.log assert log["avg_heartrate"] == 159 assert log["max_heartrate"] == 183 assert log["activity_type"] == "Run" def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.log.get("calories") is None class TestComputeTrailStats: def test_computes_distance_and_elevation(self, sample_gpx_path): result = parse_trackpoints(sample_gpx_path) stats = compute_trail_stats(result["points"]) assert stats["distance_km"] == pytest.approx(8.2, abs=0.2) assert stats["elevation_gain_m"] == pytest.approx(260, abs=20) assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10) assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5) class TestTrailGPXImportModel: def test_create_import_model(self, db, user, sample_gpx_path): imp = TrailGPXImport.objects.create( user=user, original_filename="test_trail.gpx", ) assert imp.uuid is not None assert imp.import_type == "Trail GPX" @pytest.mark.django_db(transaction=True) def test_process_via_model(self, user, sample_gpx_path): imp = TrailGPXImport.objects.create( user=user, original_filename="Morning Run.gpx", ) with open(sample_gpx_path, "rb") as f: imp.gpx_file.save("Morning Run.gpx", File(f), save=True) imp.process() imp.refresh_from_db() assert imp.process_count == 1 assert imp.processed_finished is not None class TestFindRouteWaypoint: def test_returns_halfway_point(self, sample_gpx_path): result = parse_trackpoints(sample_gpx_path) pt = find_route_waypoint(result["points"]) assert pt is not None lat, lon = pt assert lat == pytest.approx(34.177853, abs=0.001) assert lon == pytest.approx(-118.829944, abs=0.001) def test_returns_last_point_for_short_track(self): points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)] pt = find_route_waypoint(points) assert pt == (34.001, -118.001) def test_returns_none_for_empty_points(self): assert find_route_waypoint([]) is None class TestFindByTrailhead: def test_exact_match(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) trail = Trail.objects.create(title="Test Trail", trailhead_location=geo) found = Trail.find_by_trailhead(34.190598, -118.844015) assert found == trail def test_within_tolerance(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo) found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100) assert found == trail def test_beyond_tolerance(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) Trail.objects.create(title="Far Trail", trailhead_location=geo) found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50) assert found is None def test_no_trailhead_returns_none(self, db): Trail.objects.create(title="No Location") found = Trail.find_by_trailhead(34.190598, -118.844015) assert found is None def test_same_trailhead_same_route_matches(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) trail = Trail.objects.create( title="Same Route Trail", trailhead_location=geo, route_lat=34.192167, route_lon=-118.843143, ) found = Trail.find_by_trailhead( 34.190598, -118.844015, route_lat=34.192167, route_lon=-118.843143, tolerance_m=100, ) assert found == trail def test_same_trailhead_different_route_does_not_match(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) Trail.objects.create( title="Different Route Trail", trailhead_location=geo, route_lat=34.200000, route_lon=-118.850000, ) found = Trail.find_by_trailhead( 34.190598, -118.844015, route_lat=34.192167, route_lon=-118.843143, tolerance_m=100, ) assert found is None def test_legacy_trail_without_route_still_matches(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) trail = Trail.objects.create( title="Legacy Trail", trailhead_location=geo, ) found = Trail.find_by_trailhead( 34.190598, -118.844015, route_lat=34.192167, route_lon=-118.843143, tolerance_m=100, ) assert found == trail class TestFindOrCreate: def test_find_existing(self, db): Trail.objects.create(title="Existing Trail") trail = Trail.find_or_create("Existing Trail") assert trail.title == "Existing Trail" def test_create_new(self, db): trail = Trail.find_or_create("New Trail") assert trail.title == "New Trail" assert Trail.objects.count() == 1