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:
@@ -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
|
||||
@@ -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"]
|
||||
)
|
||||
Reference in New Issue
Block a user