This commit has no recorded session.
diff
commit dccc80c615b0e450a9b494e27b452c5753cb4718Author: Colin Powell <colin@unbl.ink>Date: Sat Jun 20 00:55:16 2026 -0400 [discgolf] Fix tests and naming scheme@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/22] :vrobbler:project:personal:+* Backlog [1/23] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb@@ -604,6 +604,10 @@ independent of the email flow it was originally creatdd for ** TODO [#B] Is there way to create unique slugs for media instances :media_types: +** DONE [#A] Add tests to discgolf app :discgolf:tests:+:PROPERTIES:+:ID: 28e8344e-c3cf-19af-ce1c-cb821d4fcb5f+:END: * Version 56.0 [1/1] ** DONE [#B] Add DiscGolf as a scrobbleable media :discgolf: :PROPERTIES:@@ -0,0 +1,63 @@+import tempfile++import pytest+from django.contrib.auth import get_user_model++User = get_user_model()+++@pytest.fixture+def user(db):+ return User.objects.create(email="golfer@example.com")+++@pytest.fixture+def udisc_singles_csv_content():+ return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total+Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9+Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12+"""+++@pytest.fixture+def udisc_singles_csv_file(udisc_singles_csv_content):+ with tempfile.NamedTemporaryFile(+ mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"+ ) as f:+ f.write(udisc_singles_csv_content)+ return f.name+++@pytest.fixture+def udisc_teams_csv_content():+ return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total+Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9+Alice+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9+Charlie+Diana,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12+"""+++@pytest.fixture+def udisc_teams_csv_file(udisc_teams_csv_content):+ with tempfile.NamedTemporaryFile(+ mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"+ ) as f:+ f.write(udisc_teams_csv_content)+ return f.name+++@pytest.fixture+def udisc_csv_no_par_content():+ return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total+Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9+"""+++@pytest.fixture+def udisc_csv_no_par_file(udisc_csv_no_par_content):+ with tempfile.NamedTemporaryFile(+ mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"+ ) as f:+ f.write(udisc_csv_no_par_content)+ return f.name@@ -0,0 +1,102 @@+from discgolf.models import DiscGolfCourse, DiscGolfLogData+from scrobbles.dataclasses import BaseLogData+++class TestDiscGolfCourseModel:+ def test_create_course(self, db):+ course = DiscGolfCourse.objects.create(+ title="Maple Hill",+ layout_name="Mountains",+ number_of_holes=18,+ par_total=54,+ par_per_hole={"hole_1": 3, "hole_2": 3},+ )+ assert course.uuid is not None+ assert str(course) == "Maple Hill (Mountains)"+ assert course.subtitle == "Mountains"++ def test_subtitle_fallback(self, db):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ assert course.subtitle == ""++ def test_logdata_cls(self, db):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ assert course.logdata_cls is DiscGolfLogData+ assert issubclass(course.logdata_cls, BaseLogData)++ def test_strings(self, db):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ assert course.strings.verb == "Playing"+ assert course.strings.tags == "golf"++ def test_primary_image_url(self, db):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ assert course.primary_image_url == ""++ def test_get_absolute_url(self, db):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ url = course.get_absolute_url()+ assert str(course.uuid) in url+ assert url.startswith("/disc-golf/")++ def test_find_or_create_new(self, db):+ course = DiscGolfCourse.find_or_create(+ "New Course", layout_name="Default"+ )+ assert course.title == "New Course"+ assert course.layout_name == "Default"++ def test_find_or_create_existing(self, db):+ created = DiscGolfCourse.objects.create(+ title="Existing", layout_name="Alpha"+ )+ found = DiscGolfCourse.find_or_create("Existing", layout_name="Beta")+ assert found.id == created.id+ assert found.layout_name == "Alpha"++ def test_scrobbles_method(self, db, user):+ from datetime import datetime++ import pytz++ from scrobbles.models import Scrobble++ course = DiscGolfCourse.objects.create(title="Maple Hill")+ dt1 = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)+ dt2 = datetime(2026, 6, 14, 14, 0, 0, tzinfo=pytz.UTC)+ s1 = Scrobble.objects.create(+ user=user,+ disc_golf_course=course,+ media_type=Scrobble.MediaType.DISC_GOLF,+ timestamp=dt1,+ )+ s2 = Scrobble.objects.create(+ user=user,+ disc_golf_course=course,+ media_type=Scrobble.MediaType.DISC_GOLF,+ timestamp=dt2,+ )+ qs = course.scrobbles(user.id)+ assert list(qs) == [s1, s2]+++class TestDiscGolfLogData:+ def test_basic_logdata(self):+ data = DiscGolfLogData()+ assert data.scores is None+ assert data.weather is None+ assert data.fun_factor is None+ assert data.course_name is None++ def test_logdata_with_scores(self):+ data = DiscGolfLogData(+ scores={"Alice": {"person_id": 1, "total": 9}},+ weather="Sunny",+ fun_factor="High",+ course_name="Maple Hill",+ par=9,+ round_type="Singles",+ )+ assert data.scores["Alice"]["total"] == 9+ assert data.weather == "Sunny"+ assert data.round_type == "Singles"@@ -0,0 +1,150 @@+from unittest.mock import patch++from discgolf.models import DiscGolfCourse+from discgolf.utils import _parse_udisc_datetime, import_udisc_csv+from people.models import Person+from scrobbles.models import Scrobble+++class TestParserHelpers:+ def test_parse_udisc_datetime(self):+ dt = _parse_udisc_datetime("Jun 15, 2026 10:00 AM")+ assert dt is not None+ assert dt.year == 2026+ assert dt.month == 6+ assert dt.day == 15+ assert dt.hour == 10+ assert dt.minute == 0++ def test_parse_udisc_datetime_date_only(self):+ dt = _parse_udisc_datetime("Jun 15, 2026")+ assert dt is not None+ assert dt.year == 2026+++class TestImportUdiscCSV:+ def test_import_singles_creates_course(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ course = DiscGolfCourse.objects.filter(title="Maple Hill").first()+ assert course is not None+ assert course.layout_name == "Mountains"+ assert course.number_of_holes == 3+ assert course.par_total == 9+ assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}++ def test_import_singles_creates_scrobble(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ assert Scrobble.objects.filter(source="uDisc").count() == 1++ def test_import_singles_logdata(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ scrobble = Scrobble.objects.filter(source="uDisc").first()+ log = scrobble.log+ assert log["course_name"] == "Maple Hill"+ assert log["par"] == 9+ assert log["round_type"] == "Singles"+ assert "Alice" in log["scores"]+ assert "Bob" in log["scores"]+ assert log["scores"]["Alice"]["total"] == 9+ assert log["scores"]["Bob"]["total"] == 12++ def test_import_singles_creates_people(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ assert Person.objects.filter(name="Alice").exists()+ assert Person.objects.filter(name="Bob").exists()++ def test_import_teams_creates_scrobble(self, user, udisc_teams_csv_file):+ import_udisc_csv(udisc_teams_csv_file, user.id)+ assert Scrobble.objects.filter(source="uDisc").count() == 1++ def test_import_teams_logdata(self, user, udisc_teams_csv_file):+ import_udisc_csv(udisc_teams_csv_file, user.id)+ scrobble = Scrobble.objects.filter(source="uDisc").first()+ assert scrobble.log["round_type"] == "Teams"+ alice_bob = scrobble.log["scores"]["Alice+Bob"]+ assert "person_ids" in alice_bob+ assert len(alice_bob["person_ids"]) == 2++ def test_import_creates_team_people(self, user, udisc_teams_csv_file):+ import_udisc_csv(udisc_teams_csv_file, user.id)+ assert Person.objects.filter(name="Alice").exists()+ assert Person.objects.filter(name="Bob").exists()+ assert Person.objects.filter(name="Charlie").exists()+ assert Person.objects.filter(name="Diana").exists()++ def test_import_teams_par_per_hole(self, user, udisc_teams_csv_file):+ import_udisc_csv(udisc_teams_csv_file, user.id)+ course = DiscGolfCourse.objects.get(title="Maple Hill")+ assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}++ def test_import_no_par_returns_empty(self, user, udisc_csv_no_par_file):+ result = import_udisc_csv(udisc_csv_no_par_file, user.id)+ assert result == []++ def test_import_empty_csv(self, user, db):+ import tempfile++ with tempfile.NamedTemporaryFile(+ mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"+ ) as f:+ f.write("PlayerName,CourseName,LayoutName,StartDate,Hole1,Total\n")+ path = f.name++ result = import_udisc_csv(path, user.id)+ assert result == []++ def test_import_idempotent(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ import_udisc_csv(udisc_singles_csv_file, user.id)+ assert DiscGolfCourse.objects.filter(title="Maple Hill").count() == 1+ assert Scrobble.objects.filter(source="uDisc").count() == 2++ def test_import_course_defaults_only_on_create(+ self, user, udisc_singles_csv_file+ ):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ course = DiscGolfCourse.objects.get(title="Maple Hill")+ assert course.layout_name == "Mountains"++ course.layout_name = "Updated"+ course.save()++ import_udisc_csv(udisc_singles_csv_file, user.id)+ course.refresh_from_db()+ assert course.layout_name == "Updated"++ @patch("discgolf.utils.ScrobbleNtfyNotification")+ def test_import_sends_notification(self, mock_notification_class, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ mock_notification_class.assert_called_once()+ mock_notification_class.return_value.send.assert_called_once()++ def test_import_hole_scores_per_player(self, user, udisc_singles_csv_file):+ import_udisc_csv(udisc_singles_csv_file, user.id)+ scrobble = Scrobble.objects.filter(source="uDisc").first()+ alice = scrobble.log["scores"]["Alice"]+ assert alice["hole_1"] == 4+ assert alice["hole_2"] == 2+ assert alice["hole_3"] == 3+ bob = scrobble.log["scores"]["Bob"]+ assert bob["hole_1"] == 3+ assert bob["hole_2"] == 4+ assert bob["hole_3"] == 5++ def test_import_record_error_on_bad_data(self, user, db):+ import tempfile++ content = """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total+Par,,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9+"""+ with tempfile.NamedTemporaryFile(+ mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"+ ) as f:+ f.write(content)+ path = f.name++ errors = []+ result = import_udisc_csv(path, user.id, record_error=errors.append)+ assert len(result) == 1+ course = DiscGolfCourse.objects.first()+ assert course.title == ""@@ -0,0 +1,58 @@+from datetime import datetime++import pytz+from django.contrib.auth import get_user_model+from django.test import Client+from django.urls import reverse++from discgolf.models import DiscGolfCourse+from scrobbles.models import Scrobble++User = get_user_model()+++class TestDiscGolfCourseViews:+ def _make_scrobble(self, user, course):+ dt = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)+ return Scrobble.objects.create(+ user=user,+ disc_golf_course=course,+ media_type=Scrobble.MediaType.DISC_GOLF,+ timestamp=dt,+ )++ def test_course_list_anonymous(self, db, user):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ self._make_scrobble(user, course)+ client = Client()+ response = client.get(reverse("discgolf:course_list"))+ assert response.status_code == 200++ def test_course_list_shows_course(self, db, user):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ self._make_scrobble(user, course)+ client = Client()+ response = client.get(reverse("discgolf:course_list"))+ assert response.status_code == 200+ assert "Maple Hill" in response.content.decode()++ def test_course_detail_anonymous(self, db, user):+ course = DiscGolfCourse.objects.create(title="Maple Hill")+ self._make_scrobble(user, course)+ client = Client()+ response = client.get(+ reverse("discgolf:course_detail", kwargs={"slug": course.uuid})+ )+ assert response.status_code == 200++ def test_course_detail_shows_course(self, db, user):+ course = DiscGolfCourse.objects.create(+ title="Maple Hill", layout_name="Mountains"+ )+ self._make_scrobble(user, course)+ client = Client()+ response = client.get(+ reverse("discgolf:course_detail", kwargs={"slug": course.uuid})+ )+ assert response.status_code == 200+ assert "Maple Hill" in response.content.decode()@@ -71,6 +71,6 @@ class DiscGolfCourse(ScrobblableMixin): def scrobbles(self, user_id): Scrobble = apps.get_model("scrobbles", "Scrobble")- return Scrobble.objects.filter(user_id=user_id, disc_golf=self).order_by(+ return Scrobble.objects.filter(user_id=user_id, disc_golf_course=self).order_by( "-timestamp" )@@ -123,7 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin): "web_page", "life_event", "birding_location",- "disc_golf",+ "disc_golf_course", "long_play_last_scrobble", ) list_filter = (@@ -185,5 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin): "web_page", "life_event", "birding_location",- "disc_golf",+ "disc_golf_course", )@@ -36,7 +36,7 @@ PLAY_AGAIN_MEDIA = { "locations": "GeoLocation", "videos": "Video", "birds": "BirdingLocation",- "discgolf": "DiscGolf",+ "discgolf": "DiscGolfCourse", } MEDIA_END_PADDING_SECONDS = {@@ -0,0 +1,84 @@+# Generated by Django 4.2.29 on 2026-06-20 04:53++from django.db import migrations, models+++class Migration(migrations.Migration):++ dependencies = [+ ("scrobbles", "0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more"),+ ]++ operations = [+ migrations.RenameField(+ model_name="favoritemedia",+ old_name="disc_golf",+ new_name="disc_golf_course",+ ),+ migrations.RenameField(+ model_name="scrobble",+ old_name="disc_golf",+ new_name="disc_golf_course",+ ),+ migrations.AlterField(+ model_name="favoritemedia",+ name="media_type",+ field=models.CharField(+ choices=[+ ("Video", "Video"),+ ("Track", "Track"),+ ("PodcastEpisode", "Podcast episode"),+ ("SportEvent", "Sport event"),+ ("Book", "Book"),+ ("Paper", "Paper"),+ ("VideoGame", "Video game"),+ ("BoardGame", "Board game"),+ ("GeoLocation", "GeoLocation"),+ ("Trail", "Trail"),+ ("Beer", "Beer"),+ ("Puzzle", "Puzzle"),+ ("Food", "Food"),+ ("Task", "Task"),+ ("WebPage", "Web Page"),+ ("LifeEvent", "Life event"),+ ("Mood", "Mood"),+ ("BrickSet", "Brick set"),+ ("Channel", "Channel"),+ ("BirdingLocation", "Birding location"),+ ("DiscGolfCourse", "Disc golf"),+ ],+ max_length=20,+ ),+ ),+ migrations.AlterField(+ model_name="scrobble",+ name="media_type",+ field=models.CharField(+ choices=[+ ("Video", "Video"),+ ("Track", "Track"),+ ("PodcastEpisode", "Podcast episode"),+ ("SportEvent", "Sport event"),+ ("Book", "Book"),+ ("Paper", "Paper"),+ ("VideoGame", "Video game"),+ ("BoardGame", "Board game"),+ ("GeoLocation", "GeoLocation"),+ ("Trail", "Trail"),+ ("Beer", "Beer"),+ ("Puzzle", "Puzzle"),+ ("Food", "Food"),+ ("Task", "Task"),+ ("WebPage", "Web Page"),+ ("LifeEvent", "Life event"),+ ("Mood", "Mood"),+ ("BrickSet", "Brick set"),+ ("Channel", "Channel"),+ ("BirdingLocation", "Birding location"),+ ("DiscGolfCourse", "Disc golf"),+ ],+ default="Video",+ max_length=20,+ ),+ ),+ ]@@ -693,7 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = { "BrickSet": ("brick_set",), "Channel": ("channel",), "BirdingLocation": ("birding_location",),- "DiscGolf": ("disc_golf",),+ "DiscGolfCourse": ("disc_golf_course",), } @@ -721,7 +721,7 @@ class ScrobbleQuerySet(models.QuerySet): "mood", "brick_set", "birding_location",- "disc_golf",+ "disc_golf_course", ) def with_related_for_types(self, media_types: list[str]):@@ -772,7 +772,7 @@ class Scrobble(TimeStampedModel): BRICKSET = "BrickSet", "Brick set" CHANNEL = "Channel", "Channel" BIRDING_LOCATION = "BirdingLocation", "Birding location"- DISC_GOLF = "DiscGolf", "Disc golf"+ DISC_GOLF = "DiscGolfCourse", "Disc golf" @classmethod def list(cls):@@ -803,7 +803,7 @@ class Scrobble(TimeStampedModel): birding_location = models.ForeignKey( BirdingLocation, on_delete=models.DO_NOTHING, **BNULL )- disc_golf = models.ForeignKey(+ disc_golf_course = models.ForeignKey( DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL ) media_type = models.CharField(@@ -1377,8 +1377,8 @@ class Scrobble(TimeStampedModel): media_obj = self.channel if self.birding_location: media_obj = self.birding_location- if self.disc_golf:- media_obj = self.disc_golf+ if self.disc_golf_course:+ media_obj = self.disc_golf_course return media_obj def __str__(self):@@ -1937,7 +1937,7 @@ class FavoriteMedia(TimeStampedModel): birding_location = models.ForeignKey( BirdingLocation, on_delete=models.CASCADE, **BNULL )- disc_golf = models.ForeignKey(+ disc_golf_course = models.ForeignKey( DiscGolfCourse, on_delete=models.CASCADE, **BNULL ) media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)@@ -1990,8 +1990,8 @@ class FavoriteMedia(TimeStampedModel): media_obj = self.channel if self.birding_location: media_obj = self.birding_location- if self.disc_golf:- media_obj = self.disc_golf+ if self.disc_golf_course:+ media_obj = self.disc_golf_course return media_obj @classmethod@@ -2021,7 +2021,7 @@ class FavoriteMedia(TimeStampedModel): "Mood": "mood", "BrickSet": "brick_set", "BirdingLocation": "birding_location",- "DiscGolf": "disc_golf",+ "DiscGolfCourse": "disc_golf_course", } fk = fk_map.get(media_type)@@ -1115,7 +1115,7 @@ def toggle_favorite(request, media_type, object_id): "Mood": ("moods", "Mood"), "BrickSet": ("bricksets", "BrickSet"), "BirdingLocation": ("birds", "BirdingLocation"),- "DiscGolf": ("discgolf", "DiscGolfCourse"),+ "DiscGolfCourse": ("discgolf", "DiscGolfCourse"), } app_label, model_name = app_model_map.get(media_type, (None, None))@@ -82,8 +82,8 @@ {% endif %} <h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>- {% if DiscGolf %}- {% with scrobbles=DiscGolf count=DiscGolf_count time=DiscGolf_time %}+ {% if DiscGolfCourse %}+ {% with scrobbles=DiscGolfCourse count=DiscGolfCourse_count time=DiscGolfCourse_time %} {% include "scrobbles/_scrobble_table.html" %} {% endwith %} {% else %}