"""Рейтинг в витринах (#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"] def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine): """История игрока несёт изменение его общего рейтинга за каждую партию (#77).""" me = login(client, "Историк") gid, fids = _group(client) b = add_group_member(engine, gid, "Оппонент") _duel(client, gid, fids, me["id"], b) # новички на 1500: ±32 _duel(client, gid, fids, b, me["id"]) # реванш: 1468 обыгрывает 1532 mine = client.get(f"/api/users/{me['id']}/matches").json()["items"] theirs = client.get(f"/api/users/{b}/matches").json()["items"] # Свежие сверху: реванш первым. assert (mine[1]["rating_delta"], theirs[1]["rating_delta"]) == (32.0, -32.0) assert mine[0]["rating_delta"] == -theirs[0]["rating_delta"] < 0 # Изменения складываются в рейтинг (с точностью округления до десятых). score = _board(client)[me["id"]]["score"] assert abs(1500 + sum(i["rating_delta"] for i in mine) - score) <= 0.6 # В списке партий группы дельты нет — непонятно, чья она была бы. group_items = client.get(f"/api/groups/{gid}/matches").json()["items"] assert [i["rating_delta"] for i in group_items] == [None, None] # Режим «лучшая партия» тоже её отдаёт. client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)) best = client.get(f"/api/users/{me['id']}/matches").json()["items"] assert best[0]["rating_delta"] == 32.0