433 lines
18 KiB
Python
433 lines
18 KiB
Python
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"
|
|
)
|
|
|
|
LOOP_GPX = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
|
<trk>
|
|
<name>Loop Walk</name>
|
|
<trkseg>
|
|
<trkpt lat="40.000000" lon="-75.000000"><ele>100</ele><time>2022-01-01T10:00:00Z</time></trkpt>
|
|
<trkpt lat="40.000100" lon="-75.000100"><ele>105</ele><time>2022-01-01T10:10:00Z</time></trkpt>
|
|
</trkseg>
|
|
</trk>
|
|
</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_sets_terminus(self, user, sample_gpx_path):
|
|
import_trail_gpx(sample_gpx_path, user.id)
|
|
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
|
assert trail.trail_terminus_location is not None
|
|
assert round(trail.trail_terminus_location.lat, 6) == 34.187565
|
|
assert round(trail.trail_terminus_location.lon, 6) == -118.847091
|
|
|
|
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_lookup_existing_trail_sets_terminus(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)
|
|
trail.refresh_from_db()
|
|
assert trail.trail_terminus_location is not None
|
|
assert round(trail.trail_terminus_location.lat, 6) == 34.187565
|
|
assert round(trail.trail_terminus_location.lon, 6) == -118.847091
|
|
|
|
def test_new_trail_uses_geo_titles(self, user, sample_gpx_path):
|
|
GeoLocation.objects.create(
|
|
lat=34.190598, lon=-118.844015, title="Start Trailhead"
|
|
)
|
|
GeoLocation.objects.create(
|
|
lat=34.187565, lon=-118.847091, title="End Trailhead"
|
|
)
|
|
import_trail_gpx(sample_gpx_path, user.id)
|
|
trail = Trail.objects.get(title="Start Trailhead to End Trailhead")
|
|
assert trail.trailhead_location.title == "Start Trailhead"
|
|
assert trail.trail_terminus_location.title == "End Trailhead"
|
|
|
|
def test_new_trail_loop_uses_single_geo_title(self, user, sample_gpx_path):
|
|
GeoLocation.objects.create(
|
|
lat=34.190598, lon=-118.844015, title="Coral Canyon Loop"
|
|
)
|
|
GeoLocation.objects.create(
|
|
lat=34.187565, lon=-118.847091, title="Coral Canyon Loop"
|
|
)
|
|
import_trail_gpx(sample_gpx_path, user.id)
|
|
assert Trail.objects.filter(title="Coral Canyon Loop").exists()
|
|
|
|
def test_new_trail_falls_back_to_track_name_without_geo_titles(
|
|
self, user, sample_gpx_path
|
|
):
|
|
import_trail_gpx(sample_gpx_path, user.id)
|
|
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
|
|
|
def test_tags_point_to_point_trail_and_scrobble(self, user, sample_gpx_path):
|
|
import_trail_gpx(sample_gpx_path, user.id)
|
|
trail = Trail.objects.get(title="Morning Run ⛅")
|
|
assert set(trail.tags.names()) == {"point-to-point"}
|
|
scrobble = Scrobble.objects.get(trail=trail)
|
|
assert set(scrobble.tags.names()) == {"point-to-point"}
|
|
|
|
def test_tags_out_and_back_trail_and_scrobble(self, user, tmp_path):
|
|
gpx_path = tmp_path / "loop.gpx"
|
|
gpx_path.write_text(LOOP_GPX)
|
|
import_trail_gpx(str(gpx_path), user.id)
|
|
trail = Trail.objects.get(title="Loop Walk")
|
|
assert set(trail.tags.names()) == {"out-and-back"}
|
|
scrobble = Scrobble.objects.get(trail=trail)
|
|
assert set(scrobble.tags.names()) == {"out-and-back"}
|
|
|
|
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 TestDeriveDefaultTitle:
|
|
def test_returns_none_without_geo(self, db):
|
|
assert Trail.derive_default_title(None, None) is None
|
|
no_title = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
assert Trail.derive_default_title(None, no_title) is None
|
|
|
|
def test_returns_none_without_geo_titles(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
end = GeoLocation.objects.create(lat=3.0, lon=4.0)
|
|
assert Trail.derive_default_title(start, end) is None
|
|
|
|
def test_point_to_point(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="Start Point")
|
|
end = GeoLocation.objects.create(lat=3.0, lon=4.0, title="End Point")
|
|
assert Trail.derive_default_title(start, end) == "Start Point to End Point"
|
|
|
|
def test_same_location(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Trailhead")
|
|
end = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Trailhead")
|
|
assert Trail.derive_default_title(start, end) == "The Trailhead"
|
|
|
|
def test_loop_within_tolerance(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Loop")
|
|
end = GeoLocation.objects.create(lat=1.0005, lon=2.0005, title="The Loop End")
|
|
assert Trail.derive_default_title(start, end) == "The Loop"
|
|
|
|
|
|
class TestRouteTag:
|
|
def test_point_to_point(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
end = GeoLocation.objects.create(lat=3.0, lon=4.0)
|
|
trail = Trail.objects.create(
|
|
title="Point to Point",
|
|
trailhead_location=start,
|
|
trail_terminus_location=end,
|
|
)
|
|
assert trail.route_tag == "point-to-point"
|
|
|
|
def test_out_and_back_same_location(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
trail = Trail.objects.create(
|
|
title="Out and Back",
|
|
trailhead_location=start,
|
|
trail_terminus_location=start,
|
|
)
|
|
assert trail.route_tag == "out-and-back"
|
|
|
|
def test_out_and_back_within_jitter(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
end = GeoLocation.objects.create(lat=1.0005, lon=2.0005)
|
|
trail = Trail.objects.create(
|
|
title="Near Loop", trailhead_location=start, trail_terminus_location=end
|
|
)
|
|
assert trail.route_tag == "out-and-back"
|
|
|
|
def test_none_without_terminus(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
trail = Trail.objects.create(title="No Terminus", trailhead_location=start)
|
|
assert trail.route_tag is None
|
|
|
|
def test_signal_tags_on_create(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
end = GeoLocation.objects.create(lat=3.0, lon=4.0)
|
|
trail = Trail.objects.create(
|
|
title="Auto Tagged", trailhead_location=start, trail_terminus_location=end
|
|
)
|
|
assert set(Trail.objects.get(pk=trail.pk).tags.names()) == {"point-to-point"}
|
|
|
|
def test_signal_updates_tag_when_terminus_changes(self, db):
|
|
start = GeoLocation.objects.create(lat=1.0, lon=2.0)
|
|
end = GeoLocation.objects.create(lat=3.0, lon=4.0)
|
|
trail = Trail.objects.create(
|
|
title="Changed Route", trailhead_location=start, trail_terminus_location=end
|
|
)
|
|
trail.trail_terminus_location = start
|
|
trail.save(update_fields=["trail_terminus_location"])
|
|
assert set(Trail.objects.get(pk=trail.pk).tags.names()) == {"out-and-back"}
|
|
|
|
|
|
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
|