Профиль: история партий игрока и настройки её витрины

Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
stats_service.user_match_list: завершённые партии игрока, свежие сверху;
сборка элементов вынесена из group_match_list в общий _match_items, чтобы
не дублировать её в двух местах.

Витрина профиля задаётся двумя колонками в users (миграция 0011):
history_mode (all/best) и history_detail (compact/full). Режим применяется
на бэкенде, а не на клиенте: это витрина владельца, и в том же виде
профиль видят гости. В режиме best берётся партия с максимальными League
Points из SCORED_CTE (при равных очках — более свежая).

GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail —
гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец.

#1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 19:56:06 +03:00
co-authored by Claude Opus 5
parent bca2d7a058
commit be80891449
7 changed files with 372 additions and 15 deletions
+162 -3
View File
@@ -1,10 +1,16 @@
"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
публичный профиль."""
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
(загрузка/отдача/удаление), публичный профиль."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
from tests.conftest import (
add_group_member,
create_finished_match,
csrf_headers,
login,
start_match,
)
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
@@ -240,3 +246,156 @@ def test_prepositional_dictionary_and_fallback():
)
# Фракция, заведённая админом мимо словаря, не роняет вывод.
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
# ─── История партий в профиле (#1) ───────────────────────────────────────────
def _history(client: TestClient, user_id: int) -> dict:
r = client.get(f"/api/users/{user_id}/matches")
assert r.status_code == 200, r.text
return r.json()
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, mates, fids
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
"""В историю идут только завершённые партии этого игрока."""
me = login(client, "Историк")
gid, (mate,), fids = _group_with(client, engine, "Сосед")
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
# Незавершённая партия мест не имеет и в историю попадать не должна.
assert start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[2]},
{"user_id": mate, "faction_id": fids[3]},
],
).status_code == 200
data = _history(client, me["id"])
assert data["total"] == 1
assert [m["status"] for m in data["items"]] == ["finished"]
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
assert data["mode"] == "all"
assert data["detail"] == "compact"
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
me = login(client, "Наблюдатель")
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
# Партию заводит сосед, сам игрок в ней не участвует.
login(client, "Игрок2")
create_finished_match(
client,
gid,
[
{"user_id": mate, "faction_id": fids[0], "place": 1},
{"user_id": third, "faction_id": fids[1], "place": 2},
],
)
assert _history(client, me["id"])["total"] == 0
assert _history(client, mate)["total"] == 1
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
{"user_id": b, "faction_id": fids[2], "place": 3},
{"user_id": c, "faction_id": fids[3], "place": 4},
],
)
# Свежее, но по очкам хуже — последнее место на двоих.
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
)
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["total"] == 1
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
def test_history_prefs_saved_and_validated(client: TestClient):
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
login(client, "Настройщик")
r = client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["history_mode"] == "best"
assert r.json()["history_detail"] == "full"
assert client.get("/api/users/me").json()["history_detail"] == "full"
bad = client.patch(
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
)
assert bad.status_code == 422
assert client.get("/api/users/me").json()["history_mode"] == "best"
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
me = login(client, "Витрина")
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
for _ in range(2):
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
login(client, "Прохожий")
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["detail"] == "full"
assert data["total"] == 1