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