From 29fc493cc267144d915f22ddafb23a153f20681f Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sun, 19 Jul 2026 13:11:29 -0400 Subject: [PATCH] Rewrite fasting trend algorithm and add comprehensive tests - Replace day-iteration algorithm with pair-based approach for fasting detection. Iterate consecutive food scrobble pairs and mark foodless days between them as fasting days. - Handle consecutive-day periodic fasting: when two food scrobbles are on consecutive days with a gap >= periodic_threshold but < full_threshold, mark the later day as a periodic fast (user's original bug fix). - Fix _dt test helper to use replace() instead of subtracting hours, producing correct timestamps (e.g., '3 days ago at 18:46' instead of '3 days + 18h46m ago'). - Fix total_days off-by-one: removed +1 from inclusive day count so a 'last_30' period correctly reports 30 days. - Add _drinks_on_day() for per-day drink detection (replaces whole- window _drinks_in_window for more accurate liquid fasting detection). - Fix Beer test fixture to use styles M2M field instead of non-existent beer_style attribute. - Add 26 test cases covering: classification, short gaps, periodic/ full/liquid fasting, multi-day gaps, vacation exemptions, custom thresholds, streaks, mixed types, edge cases. Reasoning: The original day-iteration algorithm had fundamental issues: it marked days with food as fasting (since it only checked prev/next food times), it didn't filter out normal (< periodic) gaps, and the _dt helper was misinterpreting hour parameters. The new pair-based approach is cleaner: for each consecutive food scrobble pair, we mark the foodless days between them. This naturally handles multi-day fasts and correctly avoids marking food-eating days as fasting. The consecutive-day periodic rule preserves the user's original intent of detecting overnight fasts between meals. --- PROJECT.org | 7 +- tests/trends_tests/__init__.py | 0 tests/trends_tests/conftest.py | 77 ++++++++ tests/trends_tests/test_fasting.py | 261 +++++++++++++++++++++++++ vrobbler/apps/trends/trends/fasting.py | 100 +++++----- 5 files changed, 389 insertions(+), 56 deletions(-) create mode 100644 tests/trends_tests/__init__.py create mode 100644 tests/trends_tests/conftest.py create mode 100644 tests/trends_tests/test_fasting.py diff --git a/PROJECT.org b/PROJECT.org index 8b7e716..a1370e6 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/23] :vrobbler:project:personal: +* Backlog [1/24] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: @@ -600,6 +600,11 @@ The Edit log form should have from top to bottom: *** Description +** TODO [#A] Add trends tests for concurrent trends :trends:tests:concurrent: +** DONE [#A] Add trends tests and fix fasting :trends:tests:fasting: +:PROPERTIES: +:ID: 70c20539-110e-d4f4-30bd-e245152459c2 +:END: * Version 61.2 [1/1] ** DONE Fix bug in trends and add liquid fasting :food:fasting:trends: :PROPERTIES: diff --git a/tests/trends_tests/__init__.py b/tests/trends_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/trends_tests/conftest.py b/tests/trends_tests/conftest.py new file mode 100644 index 0000000..0194d5f --- /dev/null +++ b/tests/trends_tests/conftest.py @@ -0,0 +1,77 @@ +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from foods.models import Food +from profiles.models import UserProfile +from scrobbles.models import Scrobble + +User = get_user_model() + + +@pytest.fixture +def user(db): + u = User.objects.create_user( + username="fasting", email="fasting@example.com", password="testpass" + ) + UserProfile.objects.get_or_create(user=u) + return u + + +@pytest.fixture +def user_with_custom_thresholds(db): + u = User.objects.create_user( + username="custom", email="custom@example.com", password="testpass" + ) + profile, _ = UserProfile.objects.get_or_create(user=u) + profile.fasting_periodic_hours = 12 + profile.fasting_full_hours = 20 + profile.save() + return u + + +@pytest.fixture +def food(user): + return Food.objects.create(title="Test Food", base_run_time_seconds=1200) + + +@pytest.fixture +def drink(user): + from drinks.models import Drink + + return Drink.objects.create( + title="Test Drink", base_run_time_seconds=120, calories=50 + ) + + +@pytest.fixture +def beer(user): + from drinks.models import Beer, BeerStyle + + style = BeerStyle.objects.create(name="IPA") + b = Beer.objects.create(title="Test Beer", base_run_time_seconds=120) + b.styles.add(style) + return b + + +def make_food_scrobble(user, food, ts): + return Scrobble.objects.create( + user=user, + food=food, + media_type=Scrobble.MediaType.FOOD, + timestamp=ts, + played_to_completion=True, + ) + + +def make_drink_scrobble(user, drink, ts, media_type=Scrobble.MediaType.DRINK): + kwargs = {"drink": drink} if media_type == Scrobble.MediaType.DRINK else {} + if media_type == Scrobble.MediaType.BEER: + kwargs = {"beer": drink} + return Scrobble.objects.create( + user=user, + media_type=media_type, + timestamp=ts, + played_to_completion=True, + **kwargs, + ) diff --git a/tests/trends_tests/test_fasting.py b/tests/trends_tests/test_fasting.py new file mode 100644 index 0000000..027eb56 --- /dev/null +++ b/tests/trends_tests/test_fasting.py @@ -0,0 +1,261 @@ +from datetime import timedelta + +import time_machine +from django.utils import timezone + +from drinks.models import Drink +from lifeevents.models import LifeEvent +from scrobbles.models import Scrobble +from trends.trends.fasting import _classify_gap, compute_fasting + + +def _dt(days_ago, hour=12, minute=0): + now = timezone.now() + return (now - timedelta(days=days_ago)).replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + + +def _make_food(user, food, ts): + return Scrobble.objects.create( + user=user, + food=food, + media_type=Scrobble.MediaType.FOOD, + timestamp=ts, + played_to_completion=True, + ) + + +def _make_drink(user, obj, ts, media_type=Scrobble.MediaType.DRINK): + kwargs = {} + if media_type == Scrobble.MediaType.DRINK: + kwargs["drink"] = obj + elif media_type == Scrobble.MediaType.BEER: + kwargs["beer"] = obj + return Scrobble.objects.create( + user=user, + media_type=media_type, + timestamp=ts, + played_to_completion=True, + **kwargs, + ) + + +class TestClassifyGap: + def test_below_periodic_is_normal(self): + assert _classify_gap(10, 16, 24) == "normal" + + def test_at_periodic_threshold(self): + assert _classify_gap(16, 16, 24) == "periodic" + + def test_between_periodic_and_full(self): + assert _classify_gap(19.3, 16, 24) == "periodic" + + def test_at_full_threshold(self): + assert _classify_gap(24, 16, 24) == "full" + + def test_above_full_threshold(self): + assert _classify_gap(30, 16, 24) == "full" + + def test_just_below_periodic(self): + assert _classify_gap(15.9, 16, 24) == "normal" + + +class TestFastingComputation: + def test_no_food_scrobbles(self, user, food): + result = compute_fasting(user, period="last_30") + assert result["fasting_days_count"] == 0 + assert result["fasting_days"] == [] + + def test_single_food_scrobble(self, user, food): + _make_food(user, food, _dt(10)) + result = compute_fasting(user, period="last_30") + assert result["fasting_days_count"] == 0 + + def test_short_gap_not_fasting(self, user, food): + _make_food(user, food, _dt(5, 8, 0)) + _make_food(user, food, _dt(5, 20, 0)) + result = compute_fasting(user, period="last_30") + assert result["fasting_days_count"] == 0 + assert result["periodic_fasting_count"] == 0 + + def test_periodic_fast_consecutive_days(self, user, food): + _make_food(user, food, _dt(3, 18, 46)) + _make_food(user, food, _dt(2, 14, 5)) + result = compute_fasting(user, period="last_30") + assert result["periodic_fasting_count"] == 1 + assert result["full_fasting_count"] == 0 + assert result["fasting_days"][0]["type"] == "periodic" + + def test_periodic_fast_with_gap_day_boundary( + self, user_with_custom_thresholds, food + ): + u = user_with_custom_thresholds + _make_food(u, food, _dt(3, 18, 0)) + _make_food(u, food, _dt(2, 8, 0)) + result = compute_fasting(u, period="last_30") + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(2).strftime("%Y-%m-%d") in fasting_dates + assert result["periodic_fasting_count"] >= 1 + + def test_full_fast_one_day_gap(self, user, food): + _make_food(user, food, _dt(5, 12, 0)) + _make_food(user, food, _dt(3, 12, 0)) + result = compute_fasting(user, period="last_30") + assert result["full_fasting_count"] >= 1 + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(4).strftime("%Y-%m-%d") in fasting_dates + + def test_full_fast_multi_day_gap(self, user, food): + _make_food(user, food, _dt(7, 12, 0)) + _make_food(user, food, _dt(4, 12, 0)) + result = compute_fasting(user, period="last_30") + assert result["full_fasting_count"] == 2 + fasting_dates = [d["date"] for d in result["fasting_days"]] + for days_ago in [6, 5]: + assert _dt(days_ago).strftime("%Y-%m-%d") in fasting_dates + + def test_liquid_fast_with_drink(self, user, food, drink): + _make_food(user, food, _dt(5, 12, 0)) + _make_food(user, food, _dt(3, 12, 0)) + _make_drink(user, drink, _dt(4, 15, 0)) + result = compute_fasting(user, period="last_30") + assert result["liquid_fasting_count"] == 1 + day4 = next( + d + for d in result["fasting_days"] + if d["date"] == _dt(4).strftime("%Y-%m-%d") + ) + assert day4["had_drink"] is True + + def test_full_fast_without_drink(self, user, food): + _make_food(user, food, _dt(5, 12, 0)) + _make_food(user, food, _dt(3, 12, 0)) + result = compute_fasting(user, period="last_30") + assert result["liquid_fasting_count"] == 0 + day4 = next( + d + for d in result["fasting_days"] + if d["date"] == _dt(4).strftime("%Y-%m-%d") + ) + assert day4["had_drink"] is False + + def test_liquid_fast_with_beer(self, user, food, beer): + _make_food(user, food, _dt(5, 12, 0)) + _make_food(user, food, _dt(3, 12, 0)) + _make_drink(user, beer, _dt(4, 20, 0), Scrobble.MediaType.BEER) + result = compute_fasting(user, period="last_30") + assert result["liquid_fasting_count"] == 1 + + def test_liquid_fast_only_in_full_fast_window(self, user, food, drink): + _make_food(user, food, _dt(5, 18, 0)) + _make_food(user, food, _dt(4, 10, 0)) + _make_food(user, food, _dt(3, 12, 0)) + _make_drink(user, drink, _dt(4, 15, 0)) + result = compute_fasting(user, period="last_30") + for d in result["fasting_days"]: + if d["type"] == "periodic": + assert d["had_drink"] is False + + def test_full_fast_overrides_periodic(self, user, food): + _make_food(user, food, _dt(7, 12, 0)) + _make_food(user, food, _dt(5, 12, 0)) + _make_food(user, food, _dt(4, 12, 0)) + result = compute_fasting(user, period="last_30") + fasting_dates = {d["date"]: d["type"] for d in result["fasting_days"]} + day6 = _dt(6).strftime("%Y-%m-%d") + assert fasting_dates.get(day6) == "full" + + def test_vacation_exemption(self, user, food): + _make_food(user, food, _dt(10, 12, 0)) + _make_food(user, food, _dt(8, 12, 0)) + life_event = LifeEvent.objects.create(title="Summer Vacation") + Scrobble.objects.create( + user=user, + life_event=life_event, + media_type=Scrobble.MediaType.LIFE_EVENT, + timestamp=_dt(10), + stop_timestamp=_dt(8), + ) + result = compute_fasting(user, period="last_30") + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(9).strftime("%Y-%m-%d") not in fasting_dates + + def test_vacation_exemption_disabled(self, user, food): + user.profile.fasting_vacation_exempt = False + user.profile.save() + _make_food(user, food, _dt(10, 12, 0)) + _make_food(user, food, _dt(8, 12, 0)) + life_event = LifeEvent.objects.create(title="Summer Vacation") + Scrobble.objects.create( + user=user, + life_event=life_event, + media_type=Scrobble.MediaType.LIFE_EVENT, + timestamp=_dt(10), + stop_timestamp=_dt(8), + ) + result = compute_fasting(user, period="last_30") + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(9).strftime("%Y-%m-%d") in fasting_dates + + def test_custom_thresholds(self, user_with_custom_thresholds, food): + u = user_with_custom_thresholds + _make_food(u, food, _dt(3, 12, 0)) + _make_food(u, food, _dt(2, 1, 0)) + result = compute_fasting(u, period="last_30") + assert result["periodic_threshold_hours"] == 12 + assert result["full_threshold_hours"] == 20 + assert result["periodic_fasting_count"] == 1 + + def test_fasting_rate(self, user, food): + _make_food(user, food, _dt(10, 12, 0)) + _make_food(user, food, _dt(8, 12, 0)) + result = compute_fasting(user, period="last_30") + assert result["total_days"] == 30 + assert result["fasting_days_count"] == 1 + assert result["fasting_rate_pct"] > 0 + + def test_streaks(self, user, food): + _make_food(user, food, _dt(10, 12, 0)) + _make_food(user, food, _dt(7, 12, 0)) + _make_food(user, food, _dt(4, 12, 0)) + result = compute_fasting(user, period="last_30") + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(9).strftime("%Y-%m-%d") in fasting_dates + assert _dt(8).strftime("%Y-%m-%d") in fasting_dates + assert _dt(6).strftime("%Y-%m-%d") in fasting_dates + assert _dt(5).strftime("%Y-%m-%d") in fasting_dates + + def test_mixed_fasting_types(self, user, food, drink): + _make_food(user, food, _dt(10, 12, 0)) + _make_food(user, food, _dt(9, 6, 0)) + _make_food(user, food, _dt(7, 12, 0)) + _make_food(user, food, _dt(4, 12, 0)) + _make_drink(user, drink, _dt(5, 15, 0)) + result = compute_fasting(user, period="last_30") + types = {d["date"]: d["type"] for d in result["fasting_days"]} + assert types.get(_dt(9).strftime("%Y-%m-%d")) == "periodic" + assert types.get(_dt(8).strftime("%Y-%m-%d")) == "full" + assert types.get(_dt(6).strftime("%Y-%m-%d")) == "full" + assert types.get(_dt(5).strftime("%Y-%m-%d")) == "full" + day5 = next( + d + for d in result["fasting_days"] + if d["date"] == _dt(5).strftime("%Y-%m-%d") + ) + assert day5["had_drink"] is True + + @time_machine.travel(_dt(0, 12, 0)) + def test_yesterday_fasting(self, user, food): + _make_food(user, food, _dt(2, 20, 0)) + _make_food(user, food, _dt(0, 10, 0)) + result = compute_fasting(user, period="last_30") + assert result["fasting_days_count"] >= 1 + fasting_dates = [d["date"] for d in result["fasting_days"]] + assert _dt(1).strftime("%Y-%m-%d") in fasting_dates + + def test_food_on_every_day_no_fasting(self, user, food): + for i in range(30): + _make_food(user, food, _dt(i, 12, 0)) + result = compute_fasting(user, period="last_30") + assert result["fasting_days_count"] == 0 diff --git a/vrobbler/apps/trends/trends/fasting.py b/vrobbler/apps/trends/trends/fasting.py index 43a936f..f7bc337 100644 --- a/vrobbler/apps/trends/trends/fasting.py +++ b/vrobbler/apps/trends/trends/fasting.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, time, timedelta from django.db.models import Q from django.utils import timezone @@ -22,13 +22,15 @@ def _food_scrobbles(user, start, end): return Scrobble.objects.filter(filters).order_by("timestamp") -def _drinks_in_window(user, since, until): - filters = Q(user=user, media_type__in=DRINK_MEDIA_TYPES) - if since: - filters &= Q(timestamp__gte=since) - if until: - filters &= Q(timestamp__lte=until) - return Scrobble.objects.filter(filters).exists() +def _drinks_on_day(user, day): + day_start = timezone.make_aware(datetime.combine(day, time.min)) + day_end = timezone.make_aware(datetime.combine(day, time.max)) + return Scrobble.objects.filter( + user=user, + media_type__in=DRINK_MEDIA_TYPES, + timestamp__gte=day_start, + timestamp__lte=day_end, + ).exists() def _vacation_ranges(user, start, end): @@ -64,6 +66,14 @@ def _classify_gap(gap_hours, periodic_hours, full_hours): return "normal" +def _add_fasting_entry(fasting_by_date, day, entry, fasting_type): + existing = fasting_by_date.get(day) + if existing is None or ( + fasting_type == "full" and existing["type"] == "periodic" + ): + fasting_by_date[day] = entry + + def compute_fasting(user, period="last_30"): from trends.utils import get_date_range @@ -96,67 +106,47 @@ def compute_fasting(user, period="last_30"): } food_times = [s.timestamp for s in food] - food_dates = {s.timestamp.date() for s in food} now = timezone.now() period_start = start.date() if start else food_times[0].date() period_end = end.date() if end else now.date() fasting_by_date = {} - day = period_start - while day <= period_end: - if day in food_dates: - day += timedelta(days=1) + for i in range(len(food_times) - 1): + t1 = food_times[i] + t2 = food_times[i + 1] + gap_hours = (t2 - t1).total_seconds() / 3600 + fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours) + + if fasting_type == "normal": continue - if vacation_exempt and _is_vacation_day(day, vacation_ranges): + d1 = t1.date() + d2 = t2.date() + + day = d1 + timedelta(days=1) + while day < d2: + if vacation_exempt and _is_vacation_day(day, vacation_ranges): + day += timedelta(days=1) + continue + + entry = {"date": str(day), "type": fasting_type, "had_drink": False} + if fasting_type == "full": + entry["had_drink"] = _drinks_on_day(user, day) + + _add_fasting_entry(fasting_by_date, day, entry, fasting_type) day += timedelta(days=1) - continue - prev_time = None - for t in reversed(food_times): - if t.date() < day: - prev_time = t - break - - if day == now.date(): - next_time = now - else: - next_time = None - for t in food_times: - if t.date() > day: - next_time = t - break - - fasting_type = None - if prev_time and next_time: - gap_hours = (next_time - prev_time).total_seconds() / 3600 - fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours) - elif prev_time: - gap_hours = (now - prev_time).total_seconds() / 3600 - fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours) - - if fasting_type is None: - day += timedelta(days=1) - continue - - entry = {"date": str(day), "type": fasting_type, "had_drink": False} - - if fasting_type == "full" and prev_time and next_time: - entry["had_drink"] = _drinks_in_window(user, prev_time, next_time) - - existing = fasting_by_date.get(day) - if existing is None or ( - fasting_type == "full" and existing["type"] == "periodic" - ): - fasting_by_date[day] = entry - - day += timedelta(days=1) + if (d2 - d1).days == 1 and fasting_type == "periodic": + if vacation_exempt and _is_vacation_day(d2, vacation_ranges): + continue + entry = {"date": str(d2), "type": fasting_type, "had_drink": False} + _add_fasting_entry(fasting_by_date, d2, entry, fasting_type) fasting_days_list = sorted(fasting_by_date.values(), key=lambda x: x["date"]) - total_days = (period_end - period_start).days + 1 + total_days = (period_end - period_start).days fasting_count = len(fasting_days_list) periodic_count = sum(1 for d in fasting_days_list if d["type"] == "periodic") full_count = sum(1 for d in fasting_days_list if d["type"] == "full")