Рейтинг: витрины статистики на проигрывании истории
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:
@@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine)
|
||||
|
||||
|
||||
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
|
||||
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
|
||||
"""Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
|
||||
|
||||
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
|
||||
Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
|
||||
в дуэли — отнимает."""
|
||||
me = login(client, "Лучший")
|
||||
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.scoring import RatedMatch, RatedSeat, replay
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]:
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return gid, fids
|
||||
|
||||
|
||||
def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict:
|
||||
return create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": winner, "faction_id": fids[0], "place": 1},
|
||||
{"user_id": loser, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]:
|
||||
data = client.get(path).json()
|
||||
rows = data["entries"] + data["provisional"] if "entries" in data else (
|
||||
data["leaderboard"] + data["provisional"]
|
||||
)
|
||||
return {e["user_id"]: e for e in rows}
|
||||
|
||||
|
||||
def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine):
|
||||
"""Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32."""
|
||||
me = login(client, "Хозяин")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Гость")
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
|
||||
assert by_id[me["id"]]["score"] == 1532
|
||||
assert by_id[b]["score"] == 1468
|
||||
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
|
||||
assert board["entries"] == []
|
||||
assert board["min_games"] == 10
|
||||
|
||||
prof = client.get("/api/users/me/stats").json()
|
||||
assert prof["overall"]["score"] == 1532
|
||||
# Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0.
|
||||
assert prof["factions"][0]["score"] == 50.0
|
||||
|
||||
|
||||
def test_ranked_after_min_games(client: TestClient, engine):
|
||||
me = login(client, "Чемпион")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Спарринг")
|
||||
for _ in range(10):
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]}
|
||||
assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2
|
||||
assert ranks[me["id"]][1] > 1500 > ranks[b][1]
|
||||
assert isinstance(ranks[me["id"]][1], int)
|
||||
|
||||
|
||||
def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
|
||||
"""Рейтинг — функция истории: правка первой партии меняет итог после второй."""
|
||||
me = login(client, "А")
|
||||
gid, fids = _group(client)
|
||||
b = add_group_member(engine, gid, "Б")
|
||||
first = _duel(client, gid, fids, me["id"], b)
|
||||
_duel(client, gid, fids, me["id"], b)
|
||||
|
||||
def expected(first_winner: int, first_loser: int) -> dict[int, int]:
|
||||
rep = replay([
|
||||
RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1),
|
||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2),
|
||||
])
|
||||
return {uid: round(r) for uid, r in rep.ratings.items()}
|
||||
|
||||
before = expected(me["id"], b)
|
||||
assert {uid: e["score"] for uid, e in _board(client).items()} == before
|
||||
|
||||
rows = [
|
||||
{"user_id": b, "faction_id": fids[1], "place": 1},
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 2},
|
||||
]
|
||||
r = client.patch(
|
||||
f"/api/matches/{first['id']}",
|
||||
json={"participants": rows, "win_reason": "objectives"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
after = expected(b, me["id"])
|
||||
assert after != before
|
||||
assert {uid: e["score"] for uid, e in _board(client).items()} == after
|
||||
|
||||
|
||||
def test_group_rating_is_its_own_chain(client: TestClient, engine):
|
||||
"""Групповой рейтинг проигрывает только партии группы, общий — все партии."""
|
||||
me = login(client, "Путешественник")
|
||||
g1, f1 = _group(client, "Первая")
|
||||
g2, f2 = _group(client, "Вторая")
|
||||
b = add_group_member(engine, g1, "Сосед")
|
||||
c = add_group_member(engine, g2, "Соседка")
|
||||
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
|
||||
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
|
||||
|
||||
assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532
|
||||
assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468
|
||||
overall = _board(client)[me["id"]]["score"]
|
||||
assert overall not in (1532, 1468)
|
||||
assert overall == round(
|
||||
replay([
|
||||
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
|
||||
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
|
||||
]).ratings[me["id"]]
|
||||
)
|
||||
|
||||
|
||||
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
|
||||
"""Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед.
|
||||
|
||||
Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже
|
||||
с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая."""
|
||||
me = login(client, "Лучший")
|
||||
gid, fids = _group(client)
|
||||
x = add_group_member(engine, gid, "Икс")
|
||||
y = add_group_member(engine, gid, "Игрек")
|
||||
first = _duel(client, gid, fids, me["id"], x)
|
||||
_duel(client, gid, fids, me["id"], y)
|
||||
|
||||
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
|
||||
data = client.get(f"/api/users/{me['id']}/matches").json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["id"] == first["id"]
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine):
|
||||
me = login(client, "Хозяин")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
b = add_group_member(engine, gid, "Гость")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": f1, "place": 1},
|
||||
{"user_id": b, "faction_id": f2, "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
|
||||
# Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5.
|
||||
# Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5.
|
||||
assert by_id[me["id"]]["score"] == 54.5
|
||||
assert by_id[b]["score"] == 45.5
|
||||
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
|
||||
assert board["entries"] == []
|
||||
assert board["min_games"] == 10
|
||||
|
||||
# Профиль показывает тот же сглаженный рейтинг, что и топ.
|
||||
prof = client.get("/api/users/me/stats").json()
|
||||
assert prof["overall"]["score"] == 54.5
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user