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
141 lines
6.2 KiB
Python
141 lines
6.2 KiB
Python
"""Рейтинг в витринах (#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"]
|