From ba69b9f21df09728632104c616debec377d21d40 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 23:47:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=B0:=20=D0=B2?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=D0=B5=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D0=B5=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалённый из группы игрок больше не остаётся в «Списке игроков»: leaderboard получает набор текущих участников, и group_stats фильтрует по нему вывод до сортировки, так что места нумеруются без пропусков. Расчёт не меняется — групповая цепочка проигрывает все партии группы, и партии с ушедшим по-прежнему влияют на рейтинг оставшихся; счётчик партий и статистика фракций группы тоже прежние. Общий топ и главная без изменений. После удаления участника фронт сразу перезапрашивает статистику группы. Тесты: удалённый пропадает из группы, но остаётся в общем топе, рейтинг оставшегося не меняется, после возврата игрок снова в списке; ранги после удаления перенумерованы. #76 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- backend/app/services/stats_service.py | 22 +++++- backend/tests/test_group_rating_members.py | 85 ++++++++++++++++++++++ frontend/src/hooks/groups.ts | 2 + 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_group_rating_members.py diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 605e190..a4b1c52 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -109,8 +109,13 @@ def leaderboard( *, history: list[RatedMatch] | None = None, rep: Replay | None = None, + member_ids: set[int] | None = None, ) -> dict: - """Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).""" + """Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует). + + member_ids — показывать только этих игроков (топ группы — её текущий состав, #76). + Фильтруется вывод, а не расчёт: партии с ушедшим игроком остаются в цепочке и влияют + на рейтинг оставшихся. Места нумеруются уже после фильтра.""" if history is None: history = load_history(session, group_id) if rep is None: @@ -119,7 +124,8 @@ def leaderboard( by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) for m in history: for s in m.seats: - by_user[s.user_id].append((m, s)) + if member_ids is None or s.user_id in member_ids: + by_user[s.user_id].append((m, s)) users = ( {u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()} if by_user @@ -263,7 +269,15 @@ def profile_stats( def group_stats(session: Session, group_id: int) -> dict: history = load_history(session, group_id) - board = leaderboard(session, group_id, history=history, rep=replay(history)) + members = membership_service.list_members(session, group_id) + # Список игроков группы — только её текущий состав: удалённый из группы в нём не висит. + board = leaderboard( + session, + group_id, + history=history, + rep=replay(history), + member_ids={u.id for _m, u in members}, # type: ignore[misc] + ) last_played = max((m.played_at for m in history), default=None) games: dict[int, int] = defaultdict(int) @@ -293,7 +307,7 @@ def group_stats(session: Session, group_id: int) -> dict: e["user_id"] for e in board["provisional"] } inactive = [] - for _m, u in membership_service.list_members(session, group_id): + for _m, u in members: if u.id in played_ids: continue inactive.append( diff --git a/backend/tests/test_group_rating_members.py b/backend/tests/test_group_rating_members.py new file mode 100644 index 0000000..be37ac7 --- /dev/null +++ b/backend/tests/test_group_rating_members.py @@ -0,0 +1,85 @@ +"""Список игроков группы — только текущий состав (#76). + +Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в +групповой цепочке: они уже повлияли на рейтинг оставшихся.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, create_finished_match, csrf_headers, login + + +def _ids(stats: dict) -> dict[str, set[int]]: + return { + block: {e["user_id"] for e in stats[block]} + for block in ("leaderboard", "provisional", "inactive") + } + + +def _group(client: TestClient) -> tuple[int, list[int]]: + gid = client.post( + "/api/groups", json={"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 _remove(client: TestClient, gid: int, uid: int) -> None: + r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + + +def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine): + me = login(client, "Хозяин") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Ушедший") + create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": b, "faction_id": fids[1], "place": 2}, + ], + ) + before = client.get(f"/api/groups/{gid}/stats").json() + my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"]) + + _remove(client, gid, b) + + stats = client.get(f"/api/groups/{gid}/stats").json() + ids = _ids(stats) + assert all(b not in block for block in ids.values()) + assert me["id"] in ids["provisional"] + # Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже. + assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score + assert stats["total_matches"] == 1 + + board = client.get("/api/stats/leaderboard").json() + assert b in {e["user_id"] for e in board["entries"] + board["provisional"]} + + # Вернули в группу — снова в списке со своей историей. + add_group_member(engine, gid, "Ушедший") + back = client.get(f"/api/groups/{gid}/stats").json() + entry = next(e for e in back["provisional"] if e["user_id"] == b) + assert (entry["games"], entry["score"]) == (1, 1468) + + +def test_group_ranks_renumbered_after_removal(client: TestClient, engine): + me = login(client, "Первый") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Второй") + c = add_group_member(engine, gid, "Третий") + for _ in range(10): + create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": b, "faction_id": fids[1], "place": 2}, + {"user_id": c, "faction_id": fids[2], "place": 3}, + ], + ) + assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3] + + _remove(client, gid, b) + + board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"] + assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)] diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts index 6779b0e..808c898 100644 --- a/frontend/src/hooks/groups.ts +++ b/frontend/src/hooks/groups.ts @@ -138,6 +138,8 @@ export function useRemoveMember(groupId: number) { ), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }); + // Список игроков группы показывает только текущий состав — удалённый из него уходит. + qc.invalidateQueries({ queryKey: qk.groupStats(groupId) }); qc.invalidateQueries({ queryKey: qk.me }); }, }); -- 2.54.0