Six TDD tasks: sorting.py core types + parse_sort_terms; per-model maps + apply_sort + parse_find_filter; wire each of the three list views (sort + N+1 eager-load + unknown-key warning toast); regression smoke. Links new follow-up #77 (presets persist/restore sort) in spec + plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
28 KiB
List-view sort query param Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make list_games, list_sessions, list_purchases honor a signed comma-list ?sort= query param (e.g. ?sort=-playtime,name), with every visible column sortable plus stats-parity aggregates (playtime, finish date).
Architecture: A new games/sorting.py defines a per-model whitelist (SortKey → SortSpec), a pure parser (parse_sort_terms), and an applier (apply_sort) that annotates-then-orders and reports unknown keys. Each list view replaces its hardcoded order_by(...) with apply_sort(...), eager-loads row relations, and turns unknown keys into warning toasts. Backend only — clickable-header UI is #73.
Tech Stack: Django 6, Python 3.13, pytest + pytest-django. Components/views per CLAUDE.md.
Global Constraints
- Never write to
GeneratedFields (duration_calculated,duration_total,price_per_game,days_to_finish) — they are read/order-only. - Complete-word identifiers (Python + JS):
descendingnotdesc_flag,querysetnotqsin real code. - Cross-relation sorts use annotated aggregates (
Sum/Max/Min) — never bareorder_by("relation__field")for to-many relations (avoids row duplication). To-one relations (game__sort_name,device__name) may be ordered directly. - Name primitive roles with PEP 695 transparent aliases (
type SortKey = str), per the newCLAUDE.mdconvention. sorting.pystays HTTP-free — it reports unknown keys; the view emitsmessages.warning.- Tests run with
uv run --with pytest-django pytest. - Spec:
docs/superpowers/specs/2026-06-21-list-view-sort-param-design.md.
File Structure
- Create
games/sorting.py— all sorting logic (aliases,SortSpec,SortTerm,ParsedSort,SortResult, the three*_SORTSmaps +*_DEFAULT_SORT,parse_sort_terms,apply_sort,parse_find_filter). - Create
tests/test_sorting.py— unit + integration tests. - Modify
games/views/game.py— wire sort intolist_games+select_related. - Modify
games/views/session.py— wire sort intolist_sessions+select_related. - Modify
games/views/purchase.py— wire sort intolist_purchases+prefetch_related.
Task 1: sorting.py core types + parse_sort_terms
Files:
- Create:
games/sorting.py - Test:
tests/test_sorting.py
Interfaces:
-
Consumes: nothing.
-
Produces:
type SortKey = str,type SortString = str,type AnnotationName = str,type OrderField = str,type Annotations = dict[AnnotationName, Aggregate]SortSpec(expression: OrderField, annotate: Annotations | None = None)(frozen dataclass)SortTerm(NamedTuple):key: SortKey,descending: booltype SortMap = dict[SortKey, SortSpec]ParsedSort(NamedTuple):terms: list[SortTerm],unknown: list[SortKey]parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort
-
Step 1: Write the failing test
# tests/test_sorting.py
"""Tests for the list-view sorting system (games/sorting.py)."""
from games.sorting import SortSpec, SortTerm, parse_sort_terms
# A minimal map; parse_sort_terms only checks key membership, not spec internals.
SAMPLE_MAP = {"name": SortSpec("name"), "date": SortSpec("created_at")}
class TestParseSortTerms:
def test_bare_key_is_ascending(self):
parsed = parse_sort_terms("name", SAMPLE_MAP)
assert parsed.terms == [SortTerm("name", False)]
assert parsed.unknown == []
def test_dash_prefix_is_descending(self):
parsed = parse_sort_terms("-date", SAMPLE_MAP)
assert parsed.terms == [SortTerm("date", True)]
def test_multi_column_preserves_order(self):
parsed = parse_sort_terms("date,-name", SAMPLE_MAP)
assert parsed.terms == [SortTerm("date", False), SortTerm("name", True)]
def test_unknown_key_is_reported_not_raised(self):
parsed = parse_sort_terms("bogus", SAMPLE_MAP)
assert parsed.terms == []
assert parsed.unknown == ["bogus"]
def test_mixed_valid_and_unknown(self):
parsed = parse_sort_terms("-name,bogus", SAMPLE_MAP)
assert parsed.terms == [SortTerm("name", True)]
assert parsed.unknown == ["bogus"]
def test_whitespace_and_empty_tokens_ignored(self):
parsed = parse_sort_terms(" name , , -date ", SAMPLE_MAP)
assert parsed.terms == [SortTerm("name", False), SortTerm("date", True)]
def test_empty_string_yields_nothing(self):
parsed = parse_sort_terms("", SAMPLE_MAP)
assert parsed.terms == []
assert parsed.unknown == []
- Step 2: Run test to verify it fails
Run: uv run --with pytest-django pytest tests/test_sorting.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'games.sorting'.
- Step 3: Write minimal implementation
# games/sorting.py
"""Structured sorting for list views (Stash-inspired, paired with games/filters.py).
A list view maps a public sort key to a SortSpec; the URL ?sort= param is a
signed comma-list of those keys (e.g. "-playtime,name"). See
docs/superpowers/specs/2026-06-21-list-view-sort-param-design.md.
"""
from dataclasses import dataclass
from typing import NamedTuple
from django.db.models import Aggregate
type SortKey = str # public column key in a *_SORTS map and in a URL term ("playtime", "name")
type SortString = str # comma-list of signed SortKeys: the URL ?sort= value and *_DEFAULT_SORT ("-date,created")
type AnnotationName = str # an alias added via .annotate(), then referenced by SortSpec.expression
type OrderField = str # SortSpec.expression: a real model field path OR an AnnotationName
# alias name -> the ORM aggregate that computes it, applied via queryset.annotate()
# e.g. {"total_playtime": Sum("sessions__duration_total")}
type Annotations = dict[AnnotationName, Aggregate]
@dataclass(frozen=True)
class SortSpec:
expression: OrderField # unsigned; a real column path or an AnnotationName
annotate: Annotations | None = None
class SortTerm(NamedTuple):
key: SortKey
descending: bool # True = "-key" (desc), False = bare key (asc)
type SortMap = dict[SortKey, SortSpec]
class ParsedSort(NamedTuple):
terms: list[SortTerm]
unknown: list[SortKey] # keys not in the map — the view turns these into warnings
def parse_sort_terms(raw: SortString, sort_map: SortMap) -> ParsedSort:
terms: list[SortTerm] = []
unknown: list[SortKey] = []
for token in raw.split(","):
token = token.strip()
if not token:
continue
descending = token.startswith("-")
key = token.lstrip("-")
if key in sort_map:
terms.append(SortTerm(key, descending))
else:
unknown.append(key)
return ParsedSort(terms, unknown)
- Step 4: Run test to verify it passes
Run: uv run --with pytest-django pytest tests/test_sorting.py -v
Expected: PASS (7 tests).
- Step 5: Commit
git add games/sorting.py tests/test_sorting.py
git commit -m "feat(sorting): SortSpec/SortTerm types + parse_sort_terms (#68)"
Task 2: Per-model maps + apply_sort + parse_find_filter
Files:
- Modify:
games/sorting.py - Test:
tests/test_sorting.py
Interfaces:
-
Consumes: Task 1's
SortSpec,SortTerm,SortMap,parse_sort_terms;FindFilterfromgames.filters. -
Produces:
GAME_SORTS: SortMap,GAME_DEFAULT_SORT = "-created"SESSION_SORTS: SortMap,SESSION_DEFAULT_SORT = "-date,created"PURCHASE_SORTS: SortMap,PURCHASE_DEFAULT_SORT = "-purchased,-created"SortResult(NamedTuple):queryset: QuerySet,terms: list[SortTerm],unknown: list[SortKey]apply_sort(queryset, find: FindFilter, sort_map: SortMap, default_sort: SortString) -> SortResultparse_find_filter(request: HttpRequest) -> FindFilter
-
Step 1: Write the failing test
Append to tests/test_sorting.py:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from django.conf import settings
from django.test import RequestFactory
from games.filters import FindFilter
from games.models import Game, Platform, Purchase, Session
from games.sorting import (
GAME_DEFAULT_SORT,
GAME_SORTS,
PURCHASE_DEFAULT_SORT,
PURCHASE_SORTS,
SESSION_DEFAULT_SORT,
SESSION_SORTS,
apply_sort,
parse_find_filter,
)
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
def _find(sort=None):
return FindFilter(sort=sort)
@pytest.fixture
def two_games(db):
platform = Platform.objects.create(name="P", icon="p")
alpha = Game.objects.create(name="Alpha", sort_name="Alpha", platform=platform)
beta = Game.objects.create(name="Beta", sort_name="Beta", platform=platform)
return alpha, beta
class TestApplySortGames:
def test_name_ascending(self, two_games):
alpha, beta = two_games
result = apply_sort(Game.objects.all(), _find("name"), GAME_SORTS, GAME_DEFAULT_SORT)
assert list(result.queryset) == [alpha, beta]
assert result.terms[0].key == "name"
assert result.unknown == []
def test_name_descending(self, two_games):
alpha, beta = two_games
result = apply_sort(Game.objects.all(), _find("-name"), GAME_SORTS, GAME_DEFAULT_SORT)
assert list(result.queryset) == [beta, alpha]
def test_default_sort_when_absent_is_created_desc(self, two_games):
alpha, beta = two_games # beta created after alpha
result = apply_sort(Game.objects.all(), _find(None), GAME_SORTS, GAME_DEFAULT_SORT)
assert list(result.queryset) == [beta, alpha]
def test_unknown_key_reported_and_falls_back(self, two_games):
result = apply_sort(Game.objects.all(), _find("bogus"), GAME_SORTS, GAME_DEFAULT_SORT)
assert result.unknown == ["bogus"]
assert result.queryset.count() == 2 # still returns rows (default order)
def test_playtime_annotation_no_duplicate_rows(self, two_games):
alpha, _ = two_games
device = None
Session.objects.create(
game=alpha,
timestamp_start=datetime(2022, 1, 1, 10, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 1, 1, 12, tzinfo=ZONEINFO),
)
Session.objects.create(
game=alpha,
timestamp_start=datetime(2022, 1, 2, 10, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 1, 2, 11, tzinfo=ZONEINFO),
)
result = apply_sort(Game.objects.all(), _find("-playtime"), GAME_SORTS, GAME_DEFAULT_SORT)
# two sessions on alpha must not duplicate the alpha row
assert result.queryset.count() == 2
assert list(result.queryset)[0] == alpha # most playtime first
class TestParseFindFilter:
def test_reads_sort_param(self):
request = RequestFactory().get("/x", {"sort": "-playtime,name"})
assert parse_find_filter(request).sort == "-playtime,name"
def test_absent_sort_is_none(self):
request = RequestFactory().get("/x")
assert parse_find_filter(request).sort is None
class TestSortMapShapes:
def test_default_sort_keys_exist_in_maps(self):
# every key referenced by a default sort string must be defined in its map
for default, sort_map in [
(GAME_DEFAULT_SORT, GAME_SORTS),
(SESSION_DEFAULT_SORT, SESSION_SORTS),
(PURCHASE_DEFAULT_SORT, PURCHASE_SORTS),
]:
for token in default.split(","):
assert token.lstrip("-") in sort_map
- Step 2: Run test to verify it fails
Run: uv run --with pytest-django pytest tests/test_sorting.py -v
Expected: FAIL — ImportError: cannot import name 'GAME_SORTS' (and friends).
- Step 3: Write minimal implementation
Add imports at the top of games/sorting.py (merge with existing):
from django.db.models import Aggregate, Max, Min, QuerySet, Sum
from django.http import HttpRequest
from games.filters import FindFilter
Append to games/sorting.py:
# ── Per-model sort maps ─────────────────────────────────────────────────────
# Cross-relation sorts use annotated aggregates (group by PK → no row dup).
# To-one relations (game__sort_name, device__name) are ordered directly.
GAME_SORTS: SortMap = {
"name": SortSpec("name"),
"sort_name": SortSpec("sort_name"),
"year": SortSpec("year_released"),
"status": SortSpec("status"),
"wikidata": SortSpec("wikidata"),
"created": SortSpec("created_at"),
"playtime": SortSpec("total_playtime", {"total_playtime": Sum("sessions__duration_total")}),
"finished": SortSpec("last_finished", {"last_finished": Max("playevents__ended")}),
}
GAME_DEFAULT_SORT: SortString = "-created"
SESSION_SORTS: SortMap = {
"name": SortSpec("game__sort_name"),
"date": SortSpec("timestamp_start"),
"duration": SortSpec("duration_total"),
"device": SortSpec("device__name"),
"created": SortSpec("created_at"),
}
SESSION_DEFAULT_SORT: SortString = "-date,created"
PURCHASE_SORTS: SortMap = {
"name": SortSpec("first_game_name", {"first_game_name": Min("games__name")}),
"type": SortSpec("type"),
"price": SortSpec("converted_price"),
"infinite": SortSpec("infinite"),
"purchased": SortSpec("date_purchased"),
"refunded": SortSpec("date_refunded"),
"created": SortSpec("created_at"),
"finished": SortSpec("last_finished", {"last_finished": Max("games__playevents__ended")}),
}
PURCHASE_DEFAULT_SORT: SortString = "-purchased,-created"
# ── Apply ───────────────────────────────────────────────────────────────────
class SortResult(NamedTuple):
queryset: QuerySet
terms: list[SortTerm] # the order actually applied — #73's header UI consumes this
unknown: list[SortKey] # rejected keys — the view turns these into warning toasts
def apply_sort(
queryset: QuerySet, find: FindFilter, sort_map: SortMap, default_sort: SortString
) -> SortResult:
terms, unknown = parse_sort_terms(find.sort or "", sort_map)
if not terms:
# default_sort is trusted developer config — ignore any "unknown" from it
terms, _ = parse_sort_terms(default_sort, sort_map)
annotations: Annotations = {}
order_by: list[OrderField] = []
for term in terms:
spec = sort_map[term.key]
if spec.annotate:
annotations.update(spec.annotate)
order_by.append(("-" if term.descending else "") + spec.expression)
if annotations:
queryset = queryset.annotate(**annotations)
return SortResult(queryset.order_by(*order_by), terms, unknown)
def parse_find_filter(request: HttpRequest) -> FindFilter:
return FindFilter(sort=request.GET.get("sort") or None) # FindFilter.sort holds a SortString
- Step 4: Run test to verify it passes
Run: uv run --with pytest-django pytest tests/test_sorting.py -v
Expected: PASS (all Task 1 + Task 2 tests).
- Step 5: Commit
git add games/sorting.py tests/test_sorting.py
git commit -m "feat(sorting): per-model maps, apply_sort, parse_find_filter (#68)"
Task 3: Wire list_games (sort + N+1 + warnings)
Files:
- Modify:
games/views/game.py(list_games, starts line 57; base queryset line 59) - Test:
tests/test_sorting.py
Interfaces:
-
Consumes:
apply_sort,parse_find_filter,GAME_SORTS,GAME_DEFAULT_SORTfromgames.sorting. -
Produces:
GET /games/?sort=<...>honored; unknown key → warning message. -
Step 1: Write the failing test
Append to tests/test_sorting.py:
from django.contrib.messages import get_messages
from django.urls import reverse
@pytest.fixture
def logged_client(client, django_user_model):
user = django_user_model.objects.create_user(username="u", password="p")
client.force_login(user)
return client
class TestListGamesSort:
def test_sort_param_orders_rows(self, logged_client, two_games):
alpha, beta = two_games
response = logged_client.get(reverse("games:list_games"), {"sort": "-name"})
assert response.status_code == 200
body = response.content.decode()
assert body.index("Beta") < body.index("Alpha")
def test_unknown_sort_emits_warning_message(self, logged_client, two_games):
response = logged_client.get(reverse("games:list_games"), {"sort": "bogus"})
assert response.status_code == 200
warnings = [str(m) for m in get_messages(response.wsgi_request)]
assert any("bogus" in w for w in warnings)
def test_valid_sort_emits_no_warning(self, logged_client, two_games):
response = logged_client.get(reverse("games:list_games"), {"sort": "name"})
warnings = [str(m) for m in get_messages(response.wsgi_request)]
assert warnings == []
- Step 2: Run test to verify it fails
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListGamesSort -v
Expected: FAIL — test_sort_param_orders_rows fails (rows still ordered by -created_at, so -name ignored); test_unknown_sort_emits_warning_message fails (no message).
- Step 3: Write minimal implementation
In games/views/game.py, add to the imports from django.contrib:
from django.contrib import messages
Add to the games.sorting import (new import line near the other games.* imports, e.g. after from games.filters import parse_game_filter):
from games.sorting import GAME_DEFAULT_SORT, GAME_SORTS, apply_sort, parse_find_filter
Change the base queryset (line 59) from:
games = Game.objects.order_by("-created_at")
to:
games = Game.objects.select_related("platform")
Then, immediately before games, page_obj, elided_page_range = paginate(request, games) (line 89), insert:
sort = apply_sort(games, parse_find_filter(request), GAME_SORTS, GAME_DEFAULT_SORT)
games = sort.queryset
for key in sort.unknown:
messages.warning(request, f"Unknown sort field '{key}' was ignored.")
- Step 4: Run test to verify it passes
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListGamesSort -v
Expected: PASS (3 tests).
- Step 5: Commit
git add games/views/game.py tests/test_sorting.py
git commit -m "feat(games): honor ?sort= on list_games + eager-load platform (#68)"
Task 4: Wire list_sessions (sort + N+1 + warnings)
Files:
- Modify:
games/views/session.py(list_sessions, starts line 120; base queryset line 122) - Test:
tests/test_sorting.py
Interfaces:
-
Consumes:
apply_sort,parse_find_filter,SESSION_SORTS,SESSION_DEFAULT_SORT. -
Produces:
GET /sessions/?sort=<...>honored; unknown key → warning. -
Step 1: Write the failing test
Append to tests/test_sorting.py:
class TestListSessionsSort:
def test_sort_by_duration_descending(self, logged_client, two_games):
alpha, beta = two_games
Session.objects.create(
game=alpha,
timestamp_start=datetime(2022, 1, 1, 10, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 1, 1, 10, 30, tzinfo=ZONEINFO), # 30 min
)
Session.objects.create(
game=beta,
timestamp_start=datetime(2022, 1, 2, 10, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 1, 2, 13, tzinfo=ZONEINFO), # 3 h
)
response = logged_client.get(reverse("games:list_sessions"), {"sort": "-duration"})
assert response.status_code == 200
body = response.content.decode()
assert body.index("Beta") < body.index("Alpha") # longer session first
def test_unknown_sort_emits_warning(self, logged_client, two_games):
response = logged_client.get(reverse("games:list_sessions"), {"sort": "nope"})
warnings = [str(m) for m in get_messages(response.wsgi_request)]
assert any("nope" in w for w in warnings)
- Step 2: Run test to verify it fails
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListSessionsSort -v
Expected: FAIL — sort ignored / no warning.
- Step 3: Write minimal implementation
In games/views/session.py, add the imports:
from django.contrib import messages
from games.sorting import (
SESSION_DEFAULT_SORT,
SESSION_SORTS,
apply_sort,
parse_find_filter,
)
Change the base queryset (line 122) from:
sessions = Session.objects.order_by("-timestamp_start", "created_at")
to:
sessions = Session.objects.select_related("game", "game__platform", "device")
Then, immediately before sessions, page_obj, elided_page_range = paginate(request, sessions) (line 148), insert:
sort = apply_sort(sessions, parse_find_filter(request), SESSION_SORTS, SESSION_DEFAULT_SORT)
sessions = sort.queryset
for key in sort.unknown:
messages.warning(request, f"Unknown sort field '{key}' was ignored.")
Note:
last_session = sessions.latest()(line 145) runs before pagination and is unaffected byorder_by(.latest()uses the model'sget_latest_by); leave it as-is. Theapply_sortcall goes after it, beforepaginate.
- Step 4: Run test to verify it passes
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListSessionsSort -v
Expected: PASS (2 tests).
- Step 5: Commit
git add games/views/session.py tests/test_sorting.py
git commit -m "feat(sessions): honor ?sort= on list_sessions + eager-load relations (#68)"
Task 5: Wire list_purchases (sort + N+1 + warnings)
Files:
- Modify:
games/views/purchase.py(list_purchases, starts line 121; base queryset line 122) - Test:
tests/test_sorting.py
Interfaces:
-
Consumes:
apply_sort,parse_find_filter,PURCHASE_SORTS,PURCHASE_DEFAULT_SORT. -
Produces:
GET /purchases/?sort=<...>honored; unknown key → warning;name/finishedaggregate sorts do not duplicate rows. -
Step 1: Write the failing test
Append to tests/test_sorting.py:
class TestListPurchasesSort:
@pytest.fixture
def two_purchases(self, db, two_games):
alpha, beta = two_games
cheap = Purchase.objects.create(
date_purchased=datetime(2022, 1, 1, tzinfo=ZONEINFO),
price=10,
converted_price=10,
platform=alpha.platform,
)
cheap.games.add(alpha)
dear = Purchase.objects.create(
date_purchased=datetime(2022, 1, 2, tzinfo=ZONEINFO),
price=90,
converted_price=90,
platform=beta.platform,
)
dear.games.add(beta)
return cheap, dear
def test_sort_by_price_descending(self, logged_client, two_purchases):
response = logged_client.get(reverse("games:list_purchases"), {"sort": "-price"})
assert response.status_code == 200
body = response.content.decode()
assert body.index("Beta") < body.index("Alpha") # 90 before 10
def test_name_aggregate_sort_no_duplicate_rows(self, logged_client, two_purchases):
# a multi-game purchase must still render exactly one row
cheap, _ = two_purchases
from games.models import Game
extra = Game.objects.create(name="Aaa", sort_name="Aaa", platform=cheap.platform)
cheap.games.add(extra)
response = logged_client.get(reverse("games:list_purchases"), {"sort": "name"})
body = response.content.decode()
assert body.count("purchase-row-") == 2 # exactly two purchase rows
def test_unknown_sort_emits_warning(self, logged_client, two_purchases):
response = logged_client.get(reverse("games:list_purchases"), {"sort": "nope"})
warnings = [str(m) for m in get_messages(response.wsgi_request)]
assert any("nope" in w for w in warnings)
- Step 2: Run test to verify it fails
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListPurchasesSort -v
Expected: FAIL — sort ignored / no warning.
- Step 3: Write minimal implementation
In games/views/purchase.py, add the imports:
from django.contrib import messages
from games.sorting import (
PURCHASE_DEFAULT_SORT,
PURCHASE_SORTS,
apply_sort,
parse_find_filter,
)
Change the base queryset (line 122) from:
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
to:
purchases = Purchase.objects.prefetch_related("games", "games__platform")
Then, immediately before purchases, page_obj, elided_page_range = paginate(request, purchases) (line 132), insert:
sort = apply_sort(purchases, parse_find_filter(request), PURCHASE_SORTS, PURCHASE_DEFAULT_SORT)
purchases = sort.queryset
for key in sort.unknown:
messages.warning(request, f"Unknown sort field '{key}' was ignored.")
If
apply_sortever yields duplicate purchase rows for an aggregate sort (it should not —Min/Maxgroup by PK), add.distinct()afterorder_byin the view; thetest_name_aggregate_sort_no_duplicate_rowstest guards this.
- Step 4: Run test to verify it passes
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestListPurchasesSort -v
Expected: PASS (3 tests).
- Step 5: Run the full suite + lint
Run: uv run --with pytest-django pytest tests/test_sorting.py -v && make lint
Expected: all sorting tests PASS; ruff clean.
- Step 6: Commit
git add games/views/purchase.py tests/test_sorting.py
git commit -m "feat(purchases): honor ?sort= on list_purchases + eager-load games (#68)"
Task 6: Regression smoke + full suite
Files:
- Test:
tests/test_sorting.py
Interfaces:
-
Consumes: all prior tasks.
-
Produces: confidence that default order is unchanged and every map key returns 200.
-
Step 1: Write the failing test
Append to tests/test_sorting.py:
class TestDefaultOrderUnchanged:
"""The default sort strings must reproduce the pre-#68 hardcoded order."""
def test_games_default_is_created_descending(self, logged_client, two_games):
alpha, beta = two_games # beta newer
response = logged_client.get(reverse("games:list_games"))
body = response.content.decode()
assert body.index("Beta") < body.index("Alpha")
class TestEverySortKeyReturns200:
def test_all_game_keys(self, logged_client, two_games):
for key in GAME_SORTS:
for raw in (key, f"-{key}"):
response = logged_client.get(reverse("games:list_games"), {"sort": raw})
assert response.status_code == 200, raw
def test_all_session_keys(self, logged_client, two_games):
for key in SESSION_SORTS:
response = logged_client.get(reverse("games:list_sessions"), {"sort": key})
assert response.status_code == 200, key
def test_all_purchase_keys(self, logged_client, two_games):
for key in PURCHASE_SORTS:
response = logged_client.get(reverse("games:list_purchases"), {"sort": key})
assert response.status_code == 200, key
- Step 2: Run to verify it passes (these assert already-correct behavior)
Run: uv run --with pytest-django pytest tests/test_sorting.py::TestDefaultOrderUnchanged tests/test_sorting.py::TestEverySortKeyReturns200 -v
Expected: PASS. If any ?sort=<key> returns 500, that key's expression/annotation is wrong — fix the offending SortSpec in games/sorting.py and re-run.
- Step 3: Run the entire project test suite
Run: make test
Expected: PASS (no regressions; note make test also collects e2e/ — a browser must be available, or run uv run --with pytest-django pytest tests/ to scope to unit/integration tests).
- Step 4: Lint + format check
Run: make check
Expected: ruff + format + tests clean.
- Step 5: Commit
git add tests/test_sorting.py
git commit -m "test(sorting): default-order regression + all-keys smoke (#68)"
Post-implementation (not tasks)
- PR description must call out the #65 coordination (per spec): each stats "view all" link's
sort=must use a key in the target model's*_SORTSmap. Verify when #65 lands. - Follow-ups already filed: #73 (header UI), #74 (FindFilter unify + dead
direction/page/per_page), #75 (purchase free-text search), #76 (sharedlist_viewhelper), #77 (presets persist/restore sort). Do not address them here.