"""Геймификация (ТЗ 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:
# Создание задачи — микронаграда (заголовки X-Created-*)
resp_create = client.post("/api/tasks", json={"title": "Задача для XP"})
assert resp_create.headers.get("X-Created-XP") == "2"
assert resp_create.headers.get("X-Created-Coins") == "1"
task_id = resp_create.json()["id"]
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()
# XP: 2 за создание + 10 за закрытие; счётчик закрытий — только kind='task'
assert summary["total_xp"] == 12
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"] == 12
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"] == 12
def test_level_progression(client: TestClient) -> None:
# каждая задача: 2 XP за создание + 10 за закрытие = 12
# 8 закрытий = 96 — уровень 1; 9-е выводит за 100 — уровень 2
for _ in range(8):
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"] == 8
assert summary["level_span"] == 200
assert summary["level_percent"] == 4
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, monkeypatch: Any) -> None:
# редкость случайна — фиксируем roll, чтобы тест не мигал (rare при xp=10 — 5%)
monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99)
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) == 2 # событие создания + событие закрытия
close_event = next(e for e in events if e["task_id"] == task_id)
assert close_event["amount"] == 10
assert close_event["rarity"] == "common"
assert close_event["via_options"] is True
created_event = next(e for e in events if e["task_id"] is None)
assert created_event["kind"] == "create_task"
# закрытие из обычного списка — 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
# --- Микронаграды за создание (ТЗ 3.13) ---
def test_project_creation_reward(client: TestClient) -> None:
resp = client.post("/api/projects", json={"name": "Проект награды"})
assert resp.status_code == 200
assert resp.headers.get("X-Created-XP") == "5"
assert resp.headers.get("X-Created-Coins") == "2"
# XP проекта в суммарный опыт входит, счётчик закрытий — нет
summary = client.get("/api/xp").json()
assert summary["total_xp"] == 5
assert summary["completed_count"] == 0
events = client.get("/api/xp/events").json()
assert [e["kind"] for e in events] == ["create_project"]
# Сад: событие создания не рождает растение
state = client.get("/api/garden").json()
assert state["balance"] == 2
assert not [i for i in state["items"] if i["kind"] == "plant"]
def test_task_creation_reward_counts_in_xp_but_not_plants(client: TestClient) -> None:
client.post("/api/tasks", json={"title": "Только создание"})
summary = client.get("/api/xp").json()
assert summary["total_xp"] == 2
assert summary["completed_count"] == 0
state = client.get("/api/garden").json()
assert state["balance"] == 1
assert not [i for i in state["items"] if i["kind"] == "plant"]
# --- Ежедневная награда (ТЗ 3.13) ---
def test_daily_reward_once_per_day(client: TestClient) -> None:
first = client.post("/api/xp/daily")
assert first.status_code == 200
assert first.json() == {"granted": True, "coins": 50}
# Повторный визит в те же сутки — молча, без начисления
second = client.post("/api/xp/daily")
assert second.json() == {"granted": False, "coins": 0}
assert client.get("/api/garden").json()["balance"] == 50