Files
ForbiddenStarsApp/backend/tests/test_rating_stats.py
T
NotBigGhostandClaude Opus 5 6f54e0f9df История: изменение рейтинга на карточках партий профиля
GET /users/{id}/matches отдаёт у каждой партии rating_delta — изменение
общего рейтинга владельца истории за неё (один знак после запятой), в обоих
режимах, включая «лучшую партию»; история и рейтинг считаются одним
проигрыванием. В списке партий группы поле пустое: там непонятно, чья это
дельта.

Фронт: компактная карточка истории — второй строкой «Рейтинг: +12.3»,
подробная (MatchListView) — последней строкой, только когда дельта есть.
Рост — зелёным, падение — красным, минус типографский (formatRatingDelta).
schema.d.ts перегенерирован.

Тест: дуэль новичков ±32 в обеих историях, реванш с обратными знаками,
сумма изменений сходится с рейтингом, в списке группы дельты нет, режим
«лучшая» её отдаёт. #77

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-14 23:51:37 +03:00

168 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Рейтинг в витринах (#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