"""Геймификация (ТЗ 3.13): XP за закрытие задач, уровни, идемпотентность, редкость."""
from typing import Any
from fastapi.testclient import TestClient
from app.services.xp import compute_rarity
def _create_task(client: TestClient, **patch: Any) -> int:
resp = client.post("/api/tasks", json={"title": "Задача для XP"})
task_id = resp.json()["id"]
if patch:
resp2 = client.patch(f"/api/tasks/{task_id}", json=patch)
assert resp2.status_code == 200
return task_id
def test_xp_granted_on_done(client: TestClient) -> None:
task_id = _create_task(client)
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
assert resp.status_code == 200
# база 10, без приоритета и оценки — только база
assert resp.headers.get("X-Earned-XP") == "10"
summary = client.get("/api/xp").json()
assert summary["total_xp"] == 10
assert summary["level"] == 1
assert summary["completed_count"] == 1
def test_xp_weighted_by_priority_and_estimate(client: TestClient) -> None:
task_id = _create_task(client)
client.patch(
f"/api/tasks/{task_id}", json={"priority": 9, "estimated_minutes": 480}
)
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
# 10 + urgent 15 + >4ч 15 = 40
assert resp.headers.get("X-Earned-XP") == "40"
def test_xp_granted_once(client: TestClient) -> None:
task_id = _create_task(client)
client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
# возврат в работу и повторное закрытие — XP не растёт
client.patch(f"/api/tasks/{task_id}", json={"status": "in_progress"})
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
assert "X-Earned-XP" not in resp.headers
assert client.get("/api/xp").json()["total_xp"] == 10
def test_deleting_task_keeps_xp(client: TestClient) -> None:
task_id = _create_task(client)
client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
client.delete(f"/api/tasks/{task_id}")
assert client.get("/api/xp").json()["total_xp"] == 10
def test_level_progression(client: TestClient) -> None:
# 9 закрытий по 10 XP = 90 — уровень 1; 10 закрытий = 100 — уровень 2
for _ in range(9):
task_id = _create_task(client)
client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
assert client.get("/api/xp").json()["level"] == 1
task_id = _create_task(client)
client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
summary = client.get("/api/xp").json()
assert summary["level"] == 2
assert summary["level_xp"] == 0
assert summary["level_span"] == 200
assert summary["level_percent"] == 0
def test_compute_rarity_boundaries() -> None:
# epic: только крупные закрытия и только при малом roll
assert compute_rarity(30, 0.079) == "epic"
assert compute_rarity(30, 0.081) == "rare" # мимо epic, но rare-шанс 45%
assert compute_rarity(30, 0.5) == "common"
assert compute_rarity(20, 0.5) == "common" # при xp < 25 epic недоступен вообще
# rare: шанс 5% + (xp-10)*2%; xp=10 -> 5%, xp=40 -> 65%
assert compute_rarity(10, 0.049) == "rare"
assert compute_rarity(10, 0.05) == "common"
assert compute_rarity(40, 0.649) == "rare"
assert compute_rarity(40, 0.65) == "common"
# при большом roll всегда common
assert compute_rarity(40, 0.99) == "common"
def test_xp_granted_with_rarity_and_options(client: TestClient) -> None:
task_id = _create_task(client)
resp = client.patch(
f"/api/tasks/{task_id}", json={"status": "done", "earned_via": "options"}
)
assert resp.status_code == 200
assert resp.headers.get("X-Earned-XP") == "10"
assert resp.headers.get("X-Plant-Rarity") == "common"
assert resp.headers.get("X-Options-Close") == "1"
events = client.get("/api/xp/events").json()
assert len(events) == 1
assert events[0]["task_id"] == task_id
assert events[0]["amount"] == 10
assert events[0]["rarity"] == "common"
assert events[0]["via_options"] is True
# закрытие из обычного списка — via_options не ставится
task2 = _create_task(client)
resp2 = client.patch(f"/api/tasks/{task2}", json={"status": "done"})
assert resp2.headers.get("X-Plant-Rarity") == "common"
assert "X-Options-Close" not in resp2.headers
events = client.get("/api/xp/events").json()
assert {e["via_options"] for e in events} == {True, False}
def test_rarity_persists_after_grant(client: TestClient) -> None:
# событие с rarity (common при большинстве роллов) переживает перечитывание
task_id = _create_task(client)
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
rarity = resp.headers.get("X-Plant-Rarity")
assert rarity in ("common", "rare", "epic")
events = client.get("/api/xp/events").json()
assert events[0]["rarity"] == rarity
def test_mentally_hard_bonus(client: TestClient) -> None:
# метка «ментально сложная» (ТЗ 3.13): +10 к XP при закрытии
task_id = _create_task(client, mentally_hard=True)
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
# 10 база + 10 за силу воли
assert resp.headers.get("X-Earned-XP") == "20"
assert resp.headers.get("X-Mentally-Hard") == "1"
# флаг виден в выдаче задачи
assert client.get(f"/api/tasks/{task_id}").json()["mentally_hard"] is True
def test_mentally_hard_toggle_before_done(client: TestClient) -> None:
# переключатель: метку можно снять до закрытия — бонус пропадает
task_id = _create_task(client, mentally_hard=True)
resp = client.patch(f"/api/tasks/{task_id}", json={"mentally_hard": False})
assert resp.json()["mentally_hard"] is False
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
assert resp.headers.get("X-Earned-XP") == "10"
assert "X-Mentally-Hard" not in resp.headers