Профиль: любимая фракция — выбор игрока, а не вывод из статистики
Раньше «любимая» вычислялась как самая частая среди партий с ручным выбором фракции. Теперь это поле профиля: users.favorite_faction_id (FK на factions, ON DELETE SET NULL, миграция 0010) и необязательное поле в PATCH /api/users/me/profile. Роутер профиля переведён на model_dump(exclude_unset=True) и обновляет только пришедшие поля — иначе сохранение одного «о себе» обнуляло бы выбранную фракцию. «Основная фракция» логику не меняет (она и так считалась по всем партиям, включая рандомные раздачи) и переезжает в подпись «Чаще всего играет на»: FactionStat получает name_ru_prepositional из словаря предложного падежа в faction_service. Словарь в коде, а не в БД: набор фракций задан игрой; фракция вне словаря выводится в именительном. Служебное chosen_games существовало только ради старой «любимой» — убрано из SQL вместе с ней. #17 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
"""Пользователь: любимая фракция (личный выбор в профиле).
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Revision ID: 0010_user_favorite_faction
|
||||||
|
Revises: 0009_participant_eliminated
|
||||||
|
Create Date: 2026-09-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0010_user_favorite_faction"
|
||||||
|
down_revision: Union[str, None] = "0009_participant_eliminated"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
if "favorite_faction_id" not in cols:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
|
||||||
|
b.create_foreign_key(
|
||||||
|
"fk_users_favorite_faction_id",
|
||||||
|
"factions",
|
||||||
|
["favorite_faction_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
if "favorite_faction_id" in cols:
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
|
||||||
|
b.drop_column("favorite_faction_id")
|
||||||
@@ -97,6 +97,16 @@ class User(SQLModel, table=True):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
|
||||||
|
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
|
||||||
|
favorite_faction_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("factions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
|
||||||
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
|
||||||
# (выдача ачивок игрокам — следующий этап).
|
# (выдача ачивок игрокам — следующий этап).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ def update_me(
|
|||||||
active_group_id=user.active_group_id,
|
active_group_id=user.active_group_id,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
|
||||||
|
favorite_faction_id=user.favorite_faction_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,14 +83,20 @@ def update_my_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
user_service.update_bio(session, user, body.bio)
|
# Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
|
||||||
|
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
|
||||||
|
changed = body.model_dump(exclude_unset=True)
|
||||||
|
if "bio" in changed:
|
||||||
|
user_service.update_bio(session, user, changed["bio"])
|
||||||
|
if "favorite_faction_id" in changed:
|
||||||
|
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
action="update",
|
action="update",
|
||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
payload={"bio": True},
|
payload={key: True for key in changed},
|
||||||
ip=request.client.host if request.client else None,
|
ip=request.client.host if request.client else None,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class UserRead(BaseModel):
|
|||||||
active_group_id: int | None = None
|
active_group_id: int | None = None
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
|
||||||
|
favorite_faction_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
@@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
|
# Оба поля необязательны и обновляются, только если реально переданы
|
||||||
|
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
|
||||||
bio: str | None = None
|
bio: str | None = None
|
||||||
|
favorite_faction_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActiveGroupUpdate(BaseModel):
|
class ActiveGroupUpdate(BaseModel):
|
||||||
@@ -311,6 +316,8 @@ class FactionStat(BaseModel):
|
|||||||
faction_id: int
|
faction_id: int
|
||||||
code: str
|
code: str
|
||||||
name_ru: str
|
name_ru: str
|
||||||
|
# Название в предложном падеже — для строки «Чаще всего играет на …».
|
||||||
|
name_ru_prepositional: str
|
||||||
expansion_code: str
|
expansion_code: str
|
||||||
games: int
|
games: int
|
||||||
wins: int
|
wins: int
|
||||||
@@ -331,8 +338,9 @@ class ProfileStats(BaseModel):
|
|||||||
factions: list[FactionStat] = []
|
factions: list[FactionStat] = []
|
||||||
best_faction: FactionStat | None = None
|
best_faction: FactionStat | None = None
|
||||||
worst_faction: FactionStat | None = None
|
worst_faction: FactionStat | None = None
|
||||||
# Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом).
|
# Любимая — личный выбор игрока в профиле (не статистика).
|
||||||
most_played_faction: FactionStat | None = None
|
favorite_faction: FactionRead | None = None
|
||||||
|
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
|
||||||
main_faction: FactionStat | None = None
|
main_faction: FactionStat | None = None
|
||||||
recent_form: list[RecentFormItem] = []
|
recent_form: list[RecentFormItem] = []
|
||||||
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
||||||
|
|||||||
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
|
|||||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
|
||||||
|
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
|
||||||
|
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
|
||||||
|
_PREPOSITIONAL: dict[str, str] = {
|
||||||
|
"orks": "Орках",
|
||||||
|
"ultramarines": "Ультрамаринах",
|
||||||
|
"eldar": "Эльдарах",
|
||||||
|
"chaos": "Хаоситах",
|
||||||
|
"astra_militarum": "Имперской гвардии",
|
||||||
|
"tau": "Тау",
|
||||||
|
"necrons": "Некронах",
|
||||||
|
"tyranids": "Тиранидах",
|
||||||
|
"inquisition": "Инквизиции",
|
||||||
|
"sisters_of_battle": "Сёстрах битвы",
|
||||||
|
"drukhari": "Друкхари",
|
||||||
|
"adeptus_mechanicus": "Адептус Механикус",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepositional(code: str, name_ru: str) -> str:
|
||||||
|
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
|
||||||
|
return _PREPOSITIONAL.get(code, name_ru)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from sqlalchemy import text
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.models import Group, GroupMember, Match
|
from app.models import Faction, Group, GroupMember, Match, User
|
||||||
from app.services import group_service, membership_service, user_service
|
from app.services import faction_service, group_service, membership_service, user_service
|
||||||
from app.services.scoring import (
|
from app.services.scoring import (
|
||||||
FACTION_MIN_GAMES,
|
FACTION_MIN_GAMES,
|
||||||
MATCH_POINTS_SQL,
|
MATCH_POINTS_SQL,
|
||||||
@@ -141,9 +141,8 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
|
|||||||
SUM(s.is_win) AS wins,
|
SUM(s.is_win) AS wins,
|
||||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||||
AVG(s.place) AS avg_place,
|
AVG(s.place) AS avg_place,
|
||||||
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика
|
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
|
||||||
-- «лучшая/худшая», сглаживание задавило бы её к 50)
|
-- «лучшая/худшая», сглаживание задавило бы её к 50)
|
||||||
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
|
|
||||||
FROM scored s
|
FROM scored s
|
||||||
JOIN factions f ON f.id = s.faction_id
|
JOIN factions f ON f.id = s.faction_id
|
||||||
JOIN expansions e ON e.id = f.expansion_id
|
JOIN expansions e ON e.id = f.expansion_id
|
||||||
@@ -168,9 +167,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
|
|||||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||||
"avg_place": _round(r["avg_place"], 2),
|
"avg_place": _round(r["avg_place"], 2),
|
||||||
"score": _round(r["score"], 1),
|
"score": _round(r["score"], 1),
|
||||||
# Служебное (в API-схему не входит): партии, где фракция выбрана
|
"name_ru_prepositional": faction_service.prepositional(
|
||||||
# вручную, — для «любимой фракции» рандомные раздачи не в счёт.
|
r["code"], r["name_ru"]
|
||||||
"chosen_games": int(r["chosen_games"] or 0),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -196,17 +195,30 @@ def _recent_form(session: Session, user_id: int, group_id: int | None, limit: in
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
||||||
|
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None or user.favorite_faction_id is None:
|
||||||
|
return None
|
||||||
|
faction = session.get(Faction, user.favorite_faction_id)
|
||||||
|
if faction is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": faction.id,
|
||||||
|
"code": faction.code,
|
||||||
|
"name_ru": faction.name_ru,
|
||||||
|
"expansion_id": faction.expansion_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
||||||
overall = _overall_for_user(session, user_id, group_id)
|
overall = _overall_for_user(session, user_id, group_id)
|
||||||
factions = _faction_breakdown(session, user_id, group_id)
|
factions = _faction_breakdown(session, user_id, group_id)
|
||||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||||
# Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false):
|
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
|
||||||
# рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой.
|
# рандомные раздачи.
|
||||||
chosen = [f for f in factions if f["chosen_games"] > 0]
|
|
||||||
most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None
|
|
||||||
# Основная — просто самая игранная, включая рандомные раздачи.
|
|
||||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||||
return {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
|||||||
"factions": factions,
|
"factions": factions,
|
||||||
"best_faction": best,
|
"best_faction": best,
|
||||||
"worst_faction": worst,
|
"worst_faction": worst,
|
||||||
"most_played_faction": most_played,
|
"favorite_faction": _favorite_faction(session, user_id),
|
||||||
"main_faction": main,
|
"main_faction": main,
|
||||||
"recent_form": _recent_form(session, user_id, group_id),
|
"recent_form": _recent_form(session, user_id, group_id),
|
||||||
"min_games": MIN_GAMES,
|
"min_games": MIN_GAMES,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlmodel import Session, select
|
|||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
||||||
from app.models import AuthIdentity, GroupMember, User
|
from app.models import AuthIdentity, Faction, GroupMember, User
|
||||||
|
|
||||||
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
|
||||||
_BIO_MAX = 500
|
_BIO_MAX = 500
|
||||||
@@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
|
||||||
|
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
|
||||||
|
if faction_id is not None and session.get(Faction, faction_id) is None:
|
||||||
|
raise ValidationError("Такой фракции не существует.")
|
||||||
|
user.favorite_faction_id = faction_id
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def sniff_image_ext(content: bytes) -> str | None:
|
def sniff_image_ext(content: bytes) -> str | None:
|
||||||
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
|
||||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
|
"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
|
||||||
|
публичный профиль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -126,8 +127,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
|
|||||||
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
||||||
|
|
||||||
|
|
||||||
def test_most_played_faction_ignores_random(client: TestClient, engine):
|
def test_main_faction_counts_random_games(client: TestClient, engine):
|
||||||
"""Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт."""
|
"""«Чаще всего играет на» — по всей истории, включая рандомные раздачи."""
|
||||||
me = login(client, "Фракционер")
|
me = login(client, "Фракционер")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
@@ -156,17 +157,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine):
|
|||||||
)
|
)
|
||||||
|
|
||||||
stats = client.get("/api/users/me/stats").json()
|
stats = client.get("/api/users/me/stats").json()
|
||||||
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше
|
# Условия выбора не важны: A сыграна чаще, пусть и по рандому.
|
||||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям
|
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||||
|
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
|
||||||
# У соперника все фракции выбраны вручную — любимая считается как раньше.
|
# Любимая больше не выводится из истории — она задаётся игроком вручную.
|
||||||
login(client, "Партнёр")
|
assert stats["favorite_faction"] is None
|
||||||
stats2 = client.get("/api/users/me/stats").json()
|
|
||||||
assert stats2["most_played_faction"]["faction_id"] == fids[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
def test_main_faction_present_for_random_only_player(client: TestClient, engine):
|
||||||
"""Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе."""
|
"""Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается."""
|
||||||
me = login(client, "Рандомщик")
|
me = login(client, "Рандомщик")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
gid = client.post(
|
||||||
@@ -183,5 +182,61 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
stats = client.get("/api/users/me/stats").json()
|
stats = client.get("/api/users/me/stats").json()
|
||||||
assert stats["most_played_faction"] is None
|
assert stats["main_faction"]["faction_id"] == fids[0]
|
||||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
|
|
||||||
|
|
||||||
|
def test_favorite_faction_set_and_clear(client: TestClient):
|
||||||
|
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
|
||||||
|
login(client, "Выбирающий")
|
||||||
|
fid = client.get("/api/factions").json()[0]["id"]
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["favorite_faction_id"] == fid
|
||||||
|
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
|
||||||
|
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
|
||||||
|
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
|
||||||
|
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["favorite_faction_id"] is None
|
||||||
|
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorite_faction_rejects_unknown(client: TestClient):
|
||||||
|
login(client, "Фантазёр")
|
||||||
|
r = client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_bio_update_keeps_favorite_faction(client: TestClient):
|
||||||
|
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
|
||||||
|
login(client, "Аккуратный")
|
||||||
|
fid = client.get("/api/factions").json()[0]["id"]
|
||||||
|
client.patch(
|
||||||
|
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["bio"] == "Только био"
|
||||||
|
assert r.json()["favorite_faction_id"] == fid
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepositional_dictionary_and_fallback():
|
||||||
|
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
|
||||||
|
from app.services import faction_service
|
||||||
|
|
||||||
|
assert faction_service.prepositional("orks", "Орки") == "Орках"
|
||||||
|
assert (
|
||||||
|
faction_service.prepositional("astra_militarum", "Имперская гвардия")
|
||||||
|
== "Имперской гвардии"
|
||||||
|
)
|
||||||
|
# Фракция, заведённая админом мимо словаря, не роняет вывод.
|
||||||
|
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
|
||||||
|
|||||||
Reference in New Issue
Block a user