Рейтинг: витрины статистики на проигрывании истории

SQL-агрегат League Points (SCORED_CTE, сглаженное среднее) заменён
проигрыванием завершённых партий по порядку played_at, finished_at, id:
load_history — один запрос на партии с участниками, scoring.replay считает
рейтинг. Цепочки две: общая (все партии) и групповая (только партии группы,
K — по партиям внутри группы). Правка или удаление прошлой партии
пересчитывает всё после неё без отдельной логики.

Топ, профиль, главная и статистика группы берут игры, победы, винрейт
и среднее место из той же истории; score — рейтинг целым числом, сортировка
по неокруглённому. Главная грузит историю один раз. Лучшая партия — наибольший
ΔR в общей цепочке, лучшая/худшая фракция — средний S − E. League Points
из scoring.py удалён.

Тесты: дуэль новичков ±32, ранжирование после 10 партий, пересчёт после
правки прошлой партии, отдельная групповая цепочка, лучшая партия по ΔR,
одна загрузка истории на главной. #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-14 22:38:57 +03:00
co-authored by Claude Opus 5
parent 8aa6bb7235
commit 6ea4a67672
7 changed files with 380 additions and 291 deletions
+15 -26
View File
@@ -1,9 +1,9 @@
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз."""
"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import event
from app.services import stats_service
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
@@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
scoring.smoothed_score); этот тест ловит их расхождение."""
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
me, gid, p2 = _group_with_matches(client, engine, games=4)
profile = client.get("/api/users/me/stats").json()["overall"]
@@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine):
assert profile[field] == entry[field], field
def test_home_does_not_repeat_scored_cte(client: TestClient, engine):
"""Главная делает не больше двух проходов по SCORED_CTE.
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
"""Главная грузит историю партий один раз: топ, профиль и итог активной группы
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
me, gid, p2 = _group_with_matches(client, engine, games=2)
client.put(
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
)
seen: list[str] = []
calls: list[int | None] = []
original = stats_service.load_history
def before_execute(conn, cursor, statement, params, context, executemany):
if "WITH tie AS" in statement:
seen.append(statement)
def spy(session, group_id=None):
calls.append(group_id)
return original(session, group_id)
event.listen(engine, "before_cursor_execute", before_execute)
try:
r = client.get("/api/home")
assert r.status_code == 200, r.text
finally:
event.remove(engine, "before_cursor_execute", before_execute)
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
# ловить (сменился путь, переименован CTE).
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
monkeypatch.setattr(stats_service, "load_history", spy)
r = client.get("/api/home")
assert r.status_code == 200, r.text
assert calls == [None]
# Главная всё ещё показывает и профиль, и блок активной группы.
body = r.json()
assert body["profile"]["overall"]["games"] == 2