diff --git a/games/views/stats_links.py b/games/views/stats_links.py new file mode 100644 index 0000000..0211c97 --- /dev/null +++ b/games/views/stats_links.py @@ -0,0 +1,200 @@ +"""Filter-link builders for the stats page (issue #65). + +Each function returns a filter object describing exactly the records behind a +stats row or count; `stats_content` wraps them with `filter_url()` to link to the +matching list view. Keeping these as pure functions (no HTTP, no rendering) lets +the parity tests assert each builder's queryset count equals the stat it links +from. + +Scope: `year` is an int for a calendar year, or the "Alltime" sentinel (or any +non-int) for all-time — matching `StatsData["year"]`. For all-time the date +bounds are omitted, so the links cover every record. +""" + +from calendar import monthrange + +from common.criteria import ( + BoolCriterion, + ChoiceCriterion, + DateCriterion, + IntCriterion, + Modifier, +) +from games.filters import ( + GameFilter, + PlayEventFilter, + PurchaseFilter, + SessionFilter, +) +from games.models import Game, Purchase + + +def _is_year(year) -> bool: + return isinstance(year, int) + + +def _year_range(year: int) -> tuple[str, str]: + return (f"{year}-01-01", f"{year}-12-31") + + +def _session_bounds(year) -> dict: + """`where()` kwargs scoping sessions to the year (empty for all-time).""" + if not _is_year(year): + return {} + return {"timestamp_start__between": _year_range(year)} + + +def _purchase_bounds(year) -> dict: + if not _is_year(year): + return {} + return {"date_purchased__between": _year_range(year)} + + +# ── Sessions ───────────────────────────────────────────────────────────────── + + +def all_sessions(year) -> SessionFilter: + return SessionFilter.where(**_session_bounds(year)) + + +def sessions_for_game(game_id: int, year) -> SessionFilter: + return SessionFilter.where(game=[game_id], **_session_bounds(year)) + + +def sessions_for_platform(platform_id: int, year) -> SessionFilter: + session_filter = SessionFilter.where(**_session_bounds(year)) + session_filter.game_filter = GameFilter.where(platform=[platform_id]) + return session_filter + + +def sessions_in_month(year: int, month: int) -> SessionFilter: + last_day = monthrange(year, month)[1] + start = f"{year}-{month:02d}-01" + end = f"{year}-{month:02d}-{last_day:02d}" + return SessionFilter.where(timestamp_start__between=(start, end)) + + +# ── Games ──────────────────────────────────────────────────────────────────── + + +def games_played(year) -> GameFilter: + """Games with at least one session in scope (matches `total_games`).""" + return GameFilter(session_filter=all_sessions(year)) + + +# ── Purchases ──────────────────────────────────────────────────────────────── + + +def purchases_total(year) -> PurchaseFilter: + return PurchaseFilter.where(**_purchase_bounds(year)) + + +def purchases_refunded(year) -> PurchaseFilter: + return PurchaseFilter.where(is_refunded=True, **_purchase_bounds(year)) + + +# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ───────────── +# +# These mirror the M2M-traversing queries in `stats_data.py`. The project models +# multi-item orders as separate single-game purchases, so on that (dominant) data +# the filter system's id-set semantics match the stats queries exactly; the only +# divergence is unsplittable multi-game bundles, which the stats queries +# themselves count inconsistently. Parity is verified per category in tests. + + +def _ended_in_scope(year) -> PlayEventFilter: + """A game's finish: a playevent whose `ended` falls in scope (any, all-time).""" + if _is_year(year): + return PlayEventFilter.where(ended__between=_year_range(year)) + return PlayEventFilter.where(ended__notnull=True) + + +def _not_finished_game(year, excluded_statuses: list) -> GameFilter: + """Games that are not finished in scope: status not in `excluded_statuses` + (always includes FINISHED) and no finishing playevent in scope. + + Mirrors `not_finished_q = ~Q(status=FINISHED) & ~ended_q` plus the extra + status exclusions some categories add.""" + game_filter = GameFilter( + status=ChoiceCriterion(value=excluded_statuses, modifier=Modifier.EXCLUDES) + ) + game_filter.NOT = GameFilter(playevent_filter=_ended_in_scope(year)) + return game_filter + + +def purchases_finished(year) -> PurchaseFilter: + """Purchases whose game is finished (in scope).""" + if _is_year(year): + return PurchaseFilter( + game_filter=GameFilter(playevent_filter=_ended_in_scope(year)) + ) + # All-time `.finished()`: game status FINISHED *or* any ended playevent. + game_filter = GameFilter(status=ChoiceCriterion(value=[Game.Status.FINISHED])) + game_filter.OR = GameFilter(playevent_filter=_ended_in_scope(year)) + return PurchaseFilter(game_filter=game_filter) + + +def purchases_finished_released(year) -> PurchaseFilter: + """Finished-in-scope purchases whose game was released that year.""" + if not _is_year(year): + return purchases_finished(year) + game_filter = GameFilter( + year_released=IntCriterion(value=year, modifier=Modifier.EQUALS), + playevent_filter=_ended_in_scope(year), + ) + return PurchaseFilter(game_filter=game_filter) + + +def purchases_bought_and_finished(year) -> PurchaseFilter: + """Not-refunded purchases bought in scope whose game finished in scope.""" + purchase_filter = PurchaseFilter.where(is_refunded=False, **_purchase_bounds(year)) + purchase_filter.game_filter = GameFilter(playevent_filter=_ended_in_scope(year)) + return purchase_filter + + +def _abandoned_or_refunded() -> PurchaseFilter: + purchase_filter = PurchaseFilter( + game_filter=GameFilter(status=ChoiceCriterion(value=[Game.Status.ABANDONED])) + ) + purchase_filter.OR = PurchaseFilter(is_refunded=BoolCriterion(value=True)) + return purchase_filter + + +def purchases_dropped(year) -> PurchaseFilter: + purchase_filter = PurchaseFilter.where( + infinite=False, + type=[Purchase.GAME, Purchase.DLC], + **_purchase_bounds(year), + ) + purchase_filter.game_filter = _not_finished_game(year, [Game.Status.FINISHED]) + purchase_filter.AND = _abandoned_or_refunded() + return purchase_filter + + +def purchases_unfinished(year) -> PurchaseFilter: + purchase_filter = PurchaseFilter.where( + is_refunded=False, + infinite=False, + type=[Purchase.GAME, Purchase.DLC], + **_purchase_bounds(year), + ) + purchase_filter.game_filter = _not_finished_game( + year, + [Game.Status.FINISHED, Game.Status.RETIRED, Game.Status.ABANDONED], + ) + return purchase_filter + + +def purchases_backlog_decrease(year) -> PurchaseFilter: + """Per-year: bought before the year, game finished in the year. All-time: + equals the all-time finished count (matches `stats_data.py`).""" + if not _is_year(year): + return purchases_finished(year) + purchase_filter = PurchaseFilter( + date_purchased=DateCriterion(value=f"{year}-01-01", modifier=Modifier.LESS_THAN) + ) + purchase_filter.game_filter = GameFilter( + status=ChoiceCriterion(value=[Game.Status.FINISHED]), + playevent_filter=_ended_in_scope(year), + ) + return purchase_filter diff --git a/tests/test_stats_links.py b/tests/test_stats_links.py new file mode 100644 index 0000000..69fd2ed --- /dev/null +++ b/tests/test_stats_links.py @@ -0,0 +1,212 @@ +"""Parity tests for stats-page filter-link builders (issue #65). + +Each builder returns a filter object; the test asserts the filter's queryset +count equals the value the stats page displays for that category, so a link can +never land on a list whose total differs from the number it was clicked from. + +Data is single-game purchases (the project's modeling norm — multi-item orders +are separate single-game purchases), where the filter system's id-set semantics +match the stats queries' M2M traversal exactly. +""" + +from datetime import datetime, timezone + +import pytest + +from games.models import Game, Platform, PlayEvent, Purchase, Session +from games.views import stats_links +from games.views.stats_data import compute_stats + +YEAR = 2024 + + +def _dt(year, month=6, day=1): + return datetime(year, month, day, 12, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def world(db): + pc = Platform.objects.create(name="PC") + switch = Platform.objects.create(name="Switch") + + finished_game = Game.objects.create( + name="Finished", platform=pc, status=Game.Status.FINISHED, year_released=YEAR + ) + abandoned_game = Game.objects.create( + name="Abandoned", platform=pc, status=Game.Status.ABANDONED + ) + playing_game = Game.objects.create( + name="Playing", platform=switch, status=Game.Status.PLAYED + ) + + # Sessions: in-year on two platforms + one out-of-year (excluded). + Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 6, 1)) + Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 7, 2)) + Session.objects.create(game=playing_game, timestamp_start=_dt(YEAR, 6, 3)) + Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR - 1, 6, 1)) + + # PlayEvents: finished_game ended in-year. + PlayEvent.objects.create(game=finished_game, ended=_dt(YEAR, 8, 1)) + + # Purchases (single-game). + Purchase.objects.create( # finished, bought in-year + date_purchased=_dt(YEAR, 1, 5), type=Purchase.GAME + ).games.set([finished_game]) + Purchase.objects.create( # abandoned -> dropped + date_purchased=_dt(YEAR, 2, 5), type=Purchase.GAME + ).games.set([abandoned_game]) + Purchase.objects.create( # refunded + date_purchased=_dt(YEAR, 3, 5), + date_refunded=_dt(YEAR, 4, 5), + type=Purchase.GAME, + ).games.set([playing_game]) + Purchase.objects.create( # unfinished (playing, not refunded/finished) + date_purchased=_dt(YEAR, 5, 5), type=Purchase.GAME + ).games.set([playing_game]) + # backlog decrease: bought prior year, game finished, ended in-year + Purchase.objects.create( + date_purchased=_dt(YEAR - 1, 5, 5), type=Purchase.GAME + ).games.set([finished_game]) + + return { + "pc": pc, + "switch": switch, + "finished_game": finished_game, + "playing_game": playing_game, + } + + +def _count(filter_obj, model): + return model.objects.filter(filter_obj.to_q()).distinct().count() + + +# ── Per-row session links ──────────────────────────────────────────────────── + + +def test_sessions_for_game_matches_year_scoped_sessions(world): + game = world["finished_game"] + expected = Session.objects.filter( + timestamp_start__year=YEAR, game_id=game.id + ).count() + assert expected == 2 # guard: the out-of-year session is excluded + assert _count(stats_links.sessions_for_game(game.id, YEAR), Session) == expected + + +def test_sessions_for_platform_matches_year_scoped_sessions(world): + platform = world["pc"] + expected = Session.objects.filter( + timestamp_start__year=YEAR, game__platform_id=platform.id + ).count() + assert ( + _count(stats_links.sessions_for_platform(platform.id, YEAR), Session) + == expected + ) + + +def test_sessions_in_month_matches_that_month(world): + expected = Session.objects.filter( + timestamp_start__year=YEAR, timestamp_start__month=6 + ).count() + assert expected == 2 + assert _count(stats_links.sessions_in_month(YEAR, 6), Session) == expected + + +def test_all_sessions_matches_total_sessions(world): + stats = compute_stats(YEAR) + assert _count(stats_links.all_sessions(YEAR), Session) == stats["total_sessions"] + + +# ── Count links ────────────────────────────────────────────────────────────── + + +def test_games_played_matches_total_games(world): + stats = compute_stats(YEAR) + assert _count(stats_links.games_played(YEAR), Game) == stats["total_games"] + + +def test_total_purchases_matches_count(world): + stats = compute_stats(YEAR) + assert ( + _count(stats_links.purchases_total(YEAR), Purchase) + == stats["all_purchased_this_year_count"] + ) + + +def test_refunded_purchases_matches_count(world): + stats = compute_stats(YEAR) + assert ( + _count(stats_links.purchases_refunded(YEAR), Purchase) + == stats["all_purchased_refunded_this_year_count"] + ) + + +# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ───────────── + + +def test_dropped_matches_count(world): + stats = compute_stats(YEAR) + assert stats["dropped_count"] == 2 # guard: discriminating, non-zero + assert ( + _count(stats_links.purchases_dropped(YEAR), Purchase) == stats["dropped_count"] + ) + + +def test_unfinished_matches_count(world): + stats = compute_stats(YEAR) + assert stats["purchased_unfinished_count"] == 1 + assert ( + _count(stats_links.purchases_unfinished(YEAR), Purchase) + == stats["purchased_unfinished_count"] + ) + + +def test_finished_matches_count(world): + stats = compute_stats(YEAR) + assert stats["all_finished_this_year_count"] == 2 + assert ( + _count(stats_links.purchases_finished(YEAR), Purchase) + == stats["all_finished_this_year_count"] + ) + + +def test_finished_released_matches_count(world): + stats = compute_stats(YEAR) + assert ( + _count(stats_links.purchases_finished_released(YEAR), Purchase) + == stats["this_year_finished_this_year_count"] + ) + + +def test_bought_and_finished_matches_list(world): + stats = compute_stats(YEAR) + expected = stats["purchased_this_year_finished_this_year"].count() + assert expected == 1 + assert _count(stats_links.purchases_bought_and_finished(YEAR), Purchase) == expected + + +def test_backlog_decrease_matches_count(world): + stats = compute_stats(YEAR) + assert stats["backlog_decrease_count"] == 1 + assert ( + _count(stats_links.purchases_backlog_decrease(YEAR), Purchase) + == stats["backlog_decrease_count"] + ) + + +# ── All-time scope (no date constraint) ────────────────────────────────────── + + +def test_all_sessions_alltime_matches(world): + stats = compute_stats(None) + assert ( + _count(stats_links.all_sessions("Alltime"), Session) == stats["total_sessions"] + ) + + +def test_finished_alltime_matches_backlog(world): + stats = compute_stats(None) + # all-time backlog_decrease_count == all-time finished count + assert ( + _count(stats_links.purchases_backlog_decrease("Alltime"), Purchase) + == stats["backlog_decrease_count"] + )