Files
NotBigGhostandClaude Opus 5 1e04f132b8 Рейтинг: партии без соперников не считаются, понятная причина недоступной партии
В профиле 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
2026-09-15 00:31:01 +03:00

241 lines
12 KiB
Python
Raw Permalink 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, #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