feat(stats): filter-link builders with exact-match parity tests (#65)

stats_links.py: pure functions returning a filter per stats category
(sessions for game/platform/month, games played, total/refunded/dropped/
unfinished/finished/finished-released/bought-and-finished/backlog purchases).
Each is verified to produce a queryset whose count equals the stat it links
from, on single-game data (the modeling norm).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
This commit is contained in:
2026-06-21 15:15:22 +02:00
parent 9bc69b939b
commit 57980c407f
2 changed files with 412 additions and 0 deletions
+200
View File
@@ -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
+212
View File
@@ -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"]
)