Spaces:
Running
Running
tests: fix test failures from Kenji-routing and API guard changes
Browse filesleaderboard.py / queries.py: replace hardcoded preset_key filter with
optional character_id parameter. Routes look up Kenji at request time
and pass the ID — keeps the library functions generic.
test_phase_3a_migration: update expected head version to
0014_mature_default_rating; update backfill assertion (family→mature).
test_phase_3a_ownership: add autouse fixture to set ALLOW_CHARACTER_API=true
+ clear lru_cache so patch/clone tests see the enabled flag.
Remaining failure (test_phase_4_1_rooms::test_detail_page_includes_viktor_theme_vars)
is pre-existing from commit 8d8c009 (demo: hide ambient toggle).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app/discovery/leaderboard.py +12 -6
- app/discovery/queries.py +20 -12
- app/web/routes.py +17 -4
- tests/test_phase_3a_migration.py +3 -3
- tests/test_phase_3a_ownership.py +11 -0
app/discovery/leaderboard.py
CHANGED
|
@@ -125,10 +125,18 @@ def character_leaderboard(
|
|
| 125 |
*,
|
| 126 |
viewer: Player,
|
| 127 |
window: LeaderboardWindow = "all",
|
|
|
|
| 128 |
) -> list[CharacterLeaderboardRow]:
|
| 129 |
char_wins, char_losses, draws = _char_win_expr()
|
| 130 |
total = func.count(Match.id)
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
stmt = (
|
| 133 |
select(
|
| 134 |
Character.id,
|
|
@@ -141,11 +149,7 @@ def character_leaderboard(
|
|
| 141 |
draws.label("draws"),
|
| 142 |
)
|
| 143 |
.join(Match, Match.character_id == Character.id)
|
| 144 |
-
.where(
|
| 145 |
-
*_window_clauses(window),
|
| 146 |
-
*visible_character_filter(viewer),
|
| 147 |
-
Character.preset_key == "kenji_sato",
|
| 148 |
-
)
|
| 149 |
.group_by(Character.id, Character.name, Character.avatar_emoji, Character.current_elo)
|
| 150 |
.having(total >= MIN_MATCHES_FOR_LEADERBOARD)
|
| 151 |
)
|
|
@@ -186,11 +190,13 @@ def player_leaderboard(
|
|
| 186 |
*,
|
| 187 |
viewer: Player,
|
| 188 |
window: LeaderboardWindow = "all",
|
|
|
|
| 189 |
) -> list[PlayerLeaderboardRow]:
|
| 190 |
"""Rank players by win rate vs. characters.
|
| 191 |
|
| 192 |
A match's outcome is inverted from `_char_win_expr`: the player wins when
|
| 193 |
the character loses, etc. Abandoned matches are player losses.
|
|
|
|
| 194 |
"""
|
| 195 |
total = func.count(Match.id)
|
| 196 |
|
|
@@ -245,7 +251,7 @@ def player_leaderboard(
|
|
| 245 |
*_window_clauses(window),
|
| 246 |
*visible_character_filter(viewer),
|
| 247 |
Player.username != "legacy_system", # never include the system fallback
|
| 248 |
-
Character.
|
| 249 |
)
|
| 250 |
.group_by(Player.id, Player.username, Player.display_name, Player.elo)
|
| 251 |
.having(total >= MIN_MATCHES_FOR_LEADERBOARD)
|
|
|
|
| 125 |
*,
|
| 126 |
viewer: Player,
|
| 127 |
window: LeaderboardWindow = "all",
|
| 128 |
+
character_id: str | None = None,
|
| 129 |
) -> list[CharacterLeaderboardRow]:
|
| 130 |
char_wins, char_losses, draws = _char_win_expr()
|
| 131 |
total = func.count(Match.id)
|
| 132 |
|
| 133 |
+
where_clauses = [
|
| 134 |
+
*_window_clauses(window),
|
| 135 |
+
*visible_character_filter(viewer),
|
| 136 |
+
]
|
| 137 |
+
if character_id is not None:
|
| 138 |
+
where_clauses.append(Character.id == character_id)
|
| 139 |
+
|
| 140 |
stmt = (
|
| 141 |
select(
|
| 142 |
Character.id,
|
|
|
|
| 149 |
draws.label("draws"),
|
| 150 |
)
|
| 151 |
.join(Match, Match.character_id == Character.id)
|
| 152 |
+
.where(*where_clauses)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
.group_by(Character.id, Character.name, Character.avatar_emoji, Character.current_elo)
|
| 154 |
.having(total >= MIN_MATCHES_FOR_LEADERBOARD)
|
| 155 |
)
|
|
|
|
| 190 |
*,
|
| 191 |
viewer: Player,
|
| 192 |
window: LeaderboardWindow = "all",
|
| 193 |
+
character_id: str | None = None,
|
| 194 |
) -> list[PlayerLeaderboardRow]:
|
| 195 |
"""Rank players by win rate vs. characters.
|
| 196 |
|
| 197 |
A match's outcome is inverted from `_char_win_expr`: the player wins when
|
| 198 |
the character loses, etc. Abandoned matches are player losses.
|
| 199 |
+
If character_id is given, restrict to matches against that character.
|
| 200 |
"""
|
| 201 |
total = func.count(Match.id)
|
| 202 |
|
|
|
|
| 251 |
*_window_clauses(window),
|
| 252 |
*visible_character_filter(viewer),
|
| 253 |
Player.username != "legacy_system", # never include the system fallback
|
| 254 |
+
*([] if character_id is None else [Character.id == character_id]),
|
| 255 |
)
|
| 256 |
.group_by(Player.id, Player.username, Player.display_name, Player.elo)
|
| 257 |
.having(total >= MIN_MATCHES_FOR_LEADERBOARD)
|
app/discovery/queries.py
CHANGED
|
@@ -81,21 +81,25 @@ def list_live_matches(
|
|
| 81 |
viewer: Player,
|
| 82 |
limit: int = 20,
|
| 83 |
offset: int = 0,
|
|
|
|
| 84 |
) -> list[MatchSummary]:
|
| 85 |
"""In-progress matches the viewer can see, most-recently started first.
|
| 86 |
|
| 87 |
Excludes the viewer's own matches — they're already in them.
|
|
|
|
| 88 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
stmt = (
|
| 90 |
select(Match, Character, Player)
|
| 91 |
.join(Character, Match.character_id == Character.id)
|
| 92 |
.join(Player, Match.player_id == Player.id)
|
| 93 |
-
.where(
|
| 94 |
-
Match.status == MatchStatus.IN_PROGRESS,
|
| 95 |
-
Match.player_id != viewer.id,
|
| 96 |
-
*visible_character_filter(viewer),
|
| 97 |
-
Character.preset_key == "kenji_sato",
|
| 98 |
-
)
|
| 99 |
.order_by(Match.started_at.desc())
|
| 100 |
.offset(offset)
|
| 101 |
.limit(limit)
|
|
@@ -109,21 +113,25 @@ def list_recent_matches(
|
|
| 109 |
viewer: Player,
|
| 110 |
limit: int = 20,
|
| 111 |
offset: int = 0,
|
|
|
|
| 112 |
) -> list[MatchSummary]:
|
| 113 |
"""Completed or abandoned matches the viewer can see, most-recently ended first.
|
| 114 |
|
| 115 |
Includes the viewer's own matches so they can navigate back to a summary.
|
|
|
|
| 116 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
stmt = (
|
| 118 |
select(Match, Character, Player)
|
| 119 |
.join(Character, Match.character_id == Character.id)
|
| 120 |
.join(Player, Match.player_id == Player.id)
|
| 121 |
-
.where(
|
| 122 |
-
Match.status.in_([MatchStatus.COMPLETED, MatchStatus.RESIGNED, MatchStatus.ABANDONED]),
|
| 123 |
-
Match.ended_at.is_not(None),
|
| 124 |
-
*visible_character_filter(viewer),
|
| 125 |
-
Character.preset_key == "kenji_sato",
|
| 126 |
-
)
|
| 127 |
.order_by(Match.ended_at.desc())
|
| 128 |
.offset(offset)
|
| 129 |
.limit(limit)
|
|
|
|
| 81 |
viewer: Player,
|
| 82 |
limit: int = 20,
|
| 83 |
offset: int = 0,
|
| 84 |
+
character_id: str | None = None,
|
| 85 |
) -> list[MatchSummary]:
|
| 86 |
"""In-progress matches the viewer can see, most-recently started first.
|
| 87 |
|
| 88 |
Excludes the viewer's own matches — they're already in them.
|
| 89 |
+
If character_id is given, restrict to matches against that character.
|
| 90 |
"""
|
| 91 |
+
clauses = [
|
| 92 |
+
Match.status == MatchStatus.IN_PROGRESS,
|
| 93 |
+
Match.player_id != viewer.id,
|
| 94 |
+
*visible_character_filter(viewer),
|
| 95 |
+
]
|
| 96 |
+
if character_id is not None:
|
| 97 |
+
clauses.append(Match.character_id == character_id)
|
| 98 |
stmt = (
|
| 99 |
select(Match, Character, Player)
|
| 100 |
.join(Character, Match.character_id == Character.id)
|
| 101 |
.join(Player, Match.player_id == Player.id)
|
| 102 |
+
.where(*clauses)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
.order_by(Match.started_at.desc())
|
| 104 |
.offset(offset)
|
| 105 |
.limit(limit)
|
|
|
|
| 113 |
viewer: Player,
|
| 114 |
limit: int = 20,
|
| 115 |
offset: int = 0,
|
| 116 |
+
character_id: str | None = None,
|
| 117 |
) -> list[MatchSummary]:
|
| 118 |
"""Completed or abandoned matches the viewer can see, most-recently ended first.
|
| 119 |
|
| 120 |
Includes the viewer's own matches so they can navigate back to a summary.
|
| 121 |
+
If character_id is given, restrict to matches against that character.
|
| 122 |
"""
|
| 123 |
+
clauses = [
|
| 124 |
+
Match.status.in_([MatchStatus.COMPLETED, MatchStatus.RESIGNED, MatchStatus.ABANDONED]),
|
| 125 |
+
Match.ended_at.is_not(None),
|
| 126 |
+
*visible_character_filter(viewer),
|
| 127 |
+
]
|
| 128 |
+
if character_id is not None:
|
| 129 |
+
clauses.append(Match.character_id == character_id)
|
| 130 |
stmt = (
|
| 131 |
select(Match, Character, Player)
|
| 132 |
.join(Character, Match.character_id == Character.id)
|
| 133 |
.join(Player, Match.player_id == Player.id)
|
| 134 |
+
.where(*clauses)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
.order_by(Match.ended_at.desc())
|
| 136 |
.offset(offset)
|
| 137 |
.limit(limit)
|
app/web/routes.py
CHANGED
|
@@ -157,6 +157,14 @@ def index(
|
|
| 157 |
# ------------------------------ Discovery ------------------------------
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
@router.get("/leaderboard/characters", response_class=HTMLResponse)
|
| 161 |
def leaderboard_characters_page(
|
| 162 |
request: Request,
|
|
@@ -166,7 +174,9 @@ def leaderboard_characters_page(
|
|
| 166 |
) -> HTMLResponse:
|
| 167 |
if window not in ("all", "30d", "7d"):
|
| 168 |
window = "all"
|
| 169 |
-
rows = character_leaderboard(
|
|
|
|
|
|
|
| 170 |
return templates.TemplateResponse(
|
| 171 |
request,
|
| 172 |
"leaderboard_characters.html",
|
|
@@ -183,7 +193,9 @@ def leaderboard_players_page(
|
|
| 183 |
) -> HTMLResponse:
|
| 184 |
if window not in ("all", "30d", "7d"):
|
| 185 |
window = "all"
|
| 186 |
-
rows = player_leaderboard(
|
|
|
|
|
|
|
| 187 |
return templates.TemplateResponse(
|
| 188 |
request,
|
| 189 |
"leaderboard_players.html",
|
|
@@ -249,8 +261,9 @@ def discovery(
|
|
| 249 |
player: Player = Depends(require_player),
|
| 250 |
session: Session = Depends(get_session),
|
| 251 |
) -> HTMLResponse:
|
| 252 |
-
|
| 253 |
-
|
|
|
|
| 254 |
|
| 255 |
return templates.TemplateResponse(
|
| 256 |
request,
|
|
|
|
| 157 |
# ------------------------------ Discovery ------------------------------
|
| 158 |
|
| 159 |
|
| 160 |
+
def _kenji_character_id(session) -> str | None:
|
| 161 |
+
"""Return Kenji's character UUID, or None if not seeded yet."""
|
| 162 |
+
kenji = session.execute(
|
| 163 |
+
select(Character).where(Character.preset_key == "kenji_sato")
|
| 164 |
+
).scalar_one_or_none()
|
| 165 |
+
return kenji.id if kenji is not None else None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
@router.get("/leaderboard/characters", response_class=HTMLResponse)
|
| 169 |
def leaderboard_characters_page(
|
| 170 |
request: Request,
|
|
|
|
| 174 |
) -> HTMLResponse:
|
| 175 |
if window not in ("all", "30d", "7d"):
|
| 176 |
window = "all"
|
| 177 |
+
rows = character_leaderboard(
|
| 178 |
+
session, viewer=player, window=window, character_id=_kenji_character_id(session)
|
| 179 |
+
)
|
| 180 |
return templates.TemplateResponse(
|
| 181 |
request,
|
| 182 |
"leaderboard_characters.html",
|
|
|
|
| 193 |
) -> HTMLResponse:
|
| 194 |
if window not in ("all", "30d", "7d"):
|
| 195 |
window = "all"
|
| 196 |
+
rows = player_leaderboard(
|
| 197 |
+
session, viewer=player, window=window, character_id=_kenji_character_id(session)
|
| 198 |
+
)
|
| 199 |
return templates.TemplateResponse(
|
| 200 |
request,
|
| 201 |
"leaderboard_players.html",
|
|
|
|
| 261 |
player: Player = Depends(require_player),
|
| 262 |
session: Session = Depends(get_session),
|
| 263 |
) -> HTMLResponse:
|
| 264 |
+
kenji_id = _kenji_character_id(session)
|
| 265 |
+
live = list_live_matches(session, viewer=player, limit=20, character_id=kenji_id)
|
| 266 |
+
recent = list_recent_matches(session, viewer=player, limit=20, character_id=kenji_id)
|
| 267 |
|
| 268 |
return templates.TemplateResponse(
|
| 269 |
request,
|
tests/test_phase_3a_migration.py
CHANGED
|
@@ -56,7 +56,7 @@ def test_alembic_upgrade_head_on_fresh_db_is_idempotent(tmp_path, monkeypatch):
|
|
| 56 |
try:
|
| 57 |
cur = con.execute("SELECT version_num FROM alembic_version")
|
| 58 |
version = cur.fetchone()[0]
|
| 59 |
-
assert version == "
|
| 60 |
finally:
|
| 61 |
con.close()
|
| 62 |
|
|
@@ -155,11 +155,11 @@ def test_alembic_upgrade_backfills_pre_3a_db(tmp_path, monkeypatch):
|
|
| 155 |
).fetchone()
|
| 156 |
assert margot[0] == "family"
|
| 157 |
|
| 158 |
-
# max_content_rating present and
|
| 159 |
mcr = con.execute(
|
| 160 |
"SELECT max_content_rating FROM players WHERE id = 'p1'"
|
| 161 |
).fetchone()
|
| 162 |
-
assert mcr[0] == "
|
| 163 |
|
| 164 |
# Visibility defaulted to public.
|
| 165 |
vis = con.execute(
|
|
|
|
| 56 |
try:
|
| 57 |
cur = con.execute("SELECT version_num FROM alembic_version")
|
| 58 |
version = cur.fetchone()[0]
|
| 59 |
+
assert version == "0014_mature_default_rating"
|
| 60 |
finally:
|
| 61 |
con.close()
|
| 62 |
|
|
|
|
| 155 |
).fetchone()
|
| 156 |
assert margot[0] == "family"
|
| 157 |
|
| 158 |
+
# max_content_rating present and upgraded to mature (migration 0014 backfill).
|
| 159 |
mcr = con.execute(
|
| 160 |
"SELECT max_content_rating FROM players WHERE id = 'p1'"
|
| 161 |
).fetchone()
|
| 162 |
+
assert mcr[0] == "mature"
|
| 163 |
|
| 164 |
# Visibility defaulted to public.
|
| 165 |
vis = con.execute(
|
tests/test_phase_3a_ownership.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
from fastapi.testclient import TestClient
|
| 6 |
|
| 7 |
from app.db import SessionLocal
|
|
@@ -11,6 +12,16 @@ from app.models.match import Player
|
|
| 11 |
from tests.conftest import signup_and_login
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _client() -> TestClient:
|
| 15 |
return TestClient(create_app(), follow_redirects=False)
|
| 16 |
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import pytest
|
| 6 |
from fastapi.testclient import TestClient
|
| 7 |
|
| 8 |
from app.db import SessionLocal
|
|
|
|
| 12 |
from tests.conftest import signup_and_login
|
| 13 |
|
| 14 |
|
| 15 |
+
@pytest.fixture(autouse=True)
|
| 16 |
+
def _enable_character_api(monkeypatch):
|
| 17 |
+
"""Enable character create/edit/clone API endpoints for ownership tests."""
|
| 18 |
+
monkeypatch.setenv("ALLOW_CHARACTER_API", "true")
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
get_settings.cache_clear()
|
| 21 |
+
yield
|
| 22 |
+
get_settings.cache_clear()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def _client() -> TestClient:
|
| 26 |
return TestClient(create_app(), follow_redirects=False)
|
| 27 |
|