Files
timetracker/tests/test_stats_links.py
T
lukas 57980c407f 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
2026-06-21 15:15:22 +02:00

213 lines
7.2 KiB
Python

"""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"]
)