В профиле Ivan висели две «партии» с одним участником, которые не открывались. Dev-удаление аккаунта (dev_admin) вычёркивает игрока из партий, не трогая сами партии, и 17.06 в dev-БД так опустели партии 2, 3, 6 и 7. Они засчитывались оставшемуся в число игр с ΔR 0.0, показывались в истории и списке группы, а бэкфилл 0014 проставил им last_standing. Партия меньше чем с двумя участниками больше не считается игрой: её нет в рейтинге и счётчиках (load_history), в истории игрока и в списке партий группы; админка её по-прежнему видит. Бэкфилл last_standing в 0014 теперь требует хотя бы двух участников (на уже мигрированных БД такие партии и так скрыты). «Партия не найдена» при переходе из чужого профиля в партию группы, где зритель не состоит, — это 403; страница партии теперь так и пишет. Тест: партия, опустевшая после dev-удаления, не попадает в историю, список и счётчик группы, рейтинг считается только по настоящей партии. #80 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
241 lines
12 KiB
Python
241 lines
12 KiB
Python
"""Рейтинг в витринах (#23, #80): 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_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
|
||
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
|
||
а игры и победы — только по партиям группы."""
|
||
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"]) # во второй — поражение
|
||
|
||
overall = _board(client)[me["id"]]
|
||
assert overall["games"] == 2
|
||
assert overall["score"] == 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"]]
|
||
)
|
||
first = _board(client, f"/api/groups/{g1}/stats")[me["id"]]
|
||
second = _board(client, f"/api/groups/{g2}/stats")[me["id"]]
|
||
assert first["score"] == second["score"] == overall["score"]
|
||
assert (first["games"], first["wins"]) == (1, 1)
|
||
assert (second["games"], second["wins"]) == (1, 0)
|
||
|
||
# Главная и профиль — общие показатели; блок активной группы — игры в группе.
|
||
client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client))
|
||
home = client.get("/api/home").json()
|
||
assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == (
|
||
2, overall["score"]
|
||
)
|
||
assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"])
|
||
|
||
|
||
def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine):
|
||
"""Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно
|
||
делают игрока ранжированным и в группе, где он сыграл одну."""
|
||
me = login(client, "Ветеран")
|
||
g1, f1 = _group(client, "Старая")
|
||
b = add_group_member(engine, g1, "Спарринг")
|
||
for _ in range(10):
|
||
_duel(client, g1, f1, me["id"], b)
|
||
|
||
g2, f2 = _group(client, "Новая")
|
||
c = add_group_member(engine, g2, "Новенький")
|
||
add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл
|
||
d = add_group_member(engine, g2, "Зритель") # не играл нигде
|
||
_duel(client, g2, f2, me["id"], c)
|
||
|
||
stats = client.get(f"/api/groups/{g2}/stats").json()
|
||
ranked = {e["user_id"]: e for e in stats["leaderboard"]}
|
||
assert ranked[me["id"]]["rank"] == 1
|
||
assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True)
|
||
assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"]
|
||
assert [e["user_id"] for e in stats["provisional"]] == [c]
|
||
|
||
inactive = {e["user_id"]: e for e in stats["inactive"]}
|
||
assert inactive[b]["score"] == _board(client)[b]["score"]
|
||
assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True)
|
||
assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False)
|
||
|
||
|
||
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_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin):
|
||
"""Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия,
|
||
где остался один участник, — не игра: её нет в рейтинге, историях и списках группы
|
||
(карточка «с одним игроком» раньше висела в профиле)."""
|
||
me = login(client, "Выживший")
|
||
gid, fids = _group(client)
|
||
b = add_group_member(engine, gid, "Удалённый")
|
||
c = add_group_member(engine, gid, "Соперник")
|
||
orphan = _duel(client, gid, fids, me["id"], b)
|
||
kept = _duel(client, gid, fids, c, me["id"])
|
||
|
||
make_admin("admin", "secret123")
|
||
assert client.post(
|
||
"/api/admin/auth/login",
|
||
json={"username": "admin", "password": "secret123"},
|
||
headers=csrf_headers(client),
|
||
).status_code == 200
|
||
assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200
|
||
# Админка партию по-прежнему видит — удалить её можно оттуда.
|
||
assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200
|
||
|
||
history = client.get(f"/api/users/{me['id']}/matches").json()
|
||
assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]])
|
||
group_list = client.get(f"/api/groups/{gid}/matches").json()
|
||
assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]])
|
||
assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1
|
||
|
||
# В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32.
|
||
board = _board(client)
|
||
assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468)
|
||
assert board[c]["score"] == 1532
|
||
|
||
|
||
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
|