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
70 lines
3.3 KiB
Python
70 lines
3.3 KiB
Python
"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.services import stats_service
|
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
|
|
|
|
|
def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dict, int, int]:
|
|
me = login(client, "Статист")
|
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
|
gid = client.post(
|
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
|
).json()["id"]
|
|
p2 = add_group_member(engine, gid, "Соперник")
|
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
|
for i in range(games):
|
|
# Чередуем победителя и берём разные фракции: средние и разбивка по фракциям
|
|
# должны получиться нетривиальными, а фракции в партии не повторяться.
|
|
winner_first = i % 2 == 0
|
|
mine, theirs = fids[(2 * i) % len(fids)], fids[(2 * i + 1) % len(fids)]
|
|
create_finished_match(
|
|
client, gid,
|
|
[
|
|
{"user_id": me["id"], "faction_id": mine, "place": 1 if winner_first else 2},
|
|
{"user_id": p2, "faction_id": theirs, "place": 2 if winner_first else 1},
|
|
],
|
|
)
|
|
return me, gid, p2
|
|
|
|
|
|
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
|
"""Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
|
|
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
|
|
|
profile = client.get("/api/users/me/stats").json()["overall"]
|
|
board = client.get("/api/stats/leaderboard").json()
|
|
entry = next(
|
|
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
|
)
|
|
|
|
for field in ("games", "wins", "win_rate", "avg_place", "score"):
|
|
assert profile[field] == entry[field], field
|
|
|
|
|
|
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)
|
|
)
|
|
|
|
calls: list[int | None] = []
|
|
original = stats_service.load_history
|
|
|
|
def spy(session, group_id=None):
|
|
calls.append(group_id)
|
|
return original(session, group_id)
|
|
|
|
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
|
|
assert body["active_group"]["games"] == 2
|