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 }); }, });