"""Сад-сцена (ТЗ 3.13): монеты, магазин, апгрейды, перемещение, авторазмещение."""
from typing import Any
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.models import GardenItem
from app.services.garden import (
DECORATIONS,
EXPANSIONS,
coins_for_xp,
level_bonus_coins,
spiral_position,
)
from tests.conftest import AUTH_USER, _test_session_factory
def _create_task(client: TestClient, **patch: Any) -> int:
resp = client.post("/api/tasks", json={"title": "Задача для сада"})
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 _done(client: TestClient, task_id: int) -> dict[str, str]:
resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"})
assert resp.status_code == 200
return {
"xp": resp.headers.get("X-Earned-XP", ""),
"coins": resp.headers.get("X-Earned-Coins", ""),
}
# --- Монеты за задачу ---
def test_coins_on_done_and_idempotent(client: TestClient) -> None:
task_id = _create_task(client)
earned = _done(client, task_id)
assert int(earned["coins"]) == coins_for_xp(int(earned["xp"]))
# Повторное закрытие не добавляет ни XP, ни монет
earned2 = _done(client, task_id)
assert earned2["xp"] == "" and earned2["coins"] == ""
state = client.get("/api/garden").json()
# баланс = монеты за закрытие + микронаграда за создание задачи (+1)
assert state["balance"] == coins_for_xp(int(earned["xp"])) + 1
def test_level_bonus_coins(client: TestClient) -> None:
# Уровень 2 требует 100 XP: три тяжёлые задачи дают 3×40 = 120 → L2
for _ in range(3):
task_id = _create_task(client, priority=9, estimated_minutes=480)
_done(client, task_id)
# Монеты: 3 × 20 за задачи + 3 × 1 за создание + бонус 25×2 = 113
assert client.get("/api/garden").json()["balance"] == 113
# --- Перемещение ---
def test_move_and_validation(client: TestClient) -> None:
task_id = _create_task(client)
_done(client, task_id)
item = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant")
resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": 5, "y": 7})
assert resp.status_code == 200
state = client.get("/api/garden").json()
moved = next(i for i in state["items"] if i["id"] == item["id"])
assert (moved["x"], moved["y"]) == (5, 7)
# Вне сетки — 400
size = state["grid"]
resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": size["cols"], "y": 0})
assert resp.status_code == 400
# --- Апгрейд растений ---
def test_plant_upgrade_costs_and_402(client: TestClient, monkeypatch: Any) -> None:
# Редкость случайна — фиксируем roll: всегда common
monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99)
task_id = _create_task(client, priority=9, estimated_minutes=480)
_done(client, task_id) # 40 XP → 20 монет
plant = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant")
assert plant["rarity"] == "common"
# Первая стадия ровно по карману (20 = 20, +1 за создание задачи)
resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade")
assert resp.status_code == 200
assert resp.json()["stage"] == 1
assert resp.json()["balance"] == 1
# Вторая стадия — не хватает (50 > 1)
resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade")
assert resp.status_code == 402
assert "50" in resp.json()["detail"]
for _ in range(10):
_done(client, _create_task(client)) # 10×5 монет + создания + бонус L2 — с запасом
resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade")
assert resp.status_code == 200
assert resp.json()["stage"] == 2
# Больше нельзя — 400
resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade")
assert resp.status_code == 400
# --- Магазин ---
def test_shop_buy_decoration_and_repeatable(client: TestClient, monkeypatch: Any) -> None:
monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99)
_done(client, _create_task(client, priority=9, estimated_minutes=480))
_done(client, _create_task(client, priority=9, estimated_minutes=480)) # 40 монет
resp = client.post("/api/garden/shop/buy", json={"item_key": "fence"})
assert resp.status_code == 200
balance_after = resp.json()["balance"]
# 2 закрытия по 20 + 2 создания по 1 − изгородь
assert balance_after == 40 + 2 - DECORATIONS["fence"]["cost"]
# Покупка кладёт декорацию в инвентарь: элемент есть, координат нет
item = next(
i
for i in client.get("/api/garden").json()["items"]
if i["kind"] == "decoration" and i["item_key"] == "fence"
)
assert item["x"] is None and item["y"] is None
# Из инвентаря на карту — перетаскиванием (PATCH с координатами)
resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": 3, "y": 4})
assert resp.status_code == 200
placed = next(i for i in client.get("/api/garden").json()["items"] if i["id"] == item["id"])
assert (placed["x"], placed["y"]) == (3, 4)
# Обратно в инвентарь (x/y None — только декорациям)
resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": None, "y": None})
assert resp.status_code == 200
stored = next(i for i in client.get("/api/garden").json()["items"] if i["id"] == item["id"])
assert stored["x"] is None and stored["y"] is None
# Растение в инвентарь убрать нельзя
plant = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant")
resp = client.patch(f"/api/garden/items/{plant['id']}", json={"x": None, "y": None})
assert resp.status_code == 400
def test_shop_unknown_400_and_unique_409(client: TestClient, monkeypatch: Any) -> None:
assert client.post("/api/garden/shop/buy", json={"item_key": "nope"}).status_code == 400
# Уникальную декорацию нельзя купить дважды: 6 задач по 40 XP = 240 XP (L2),
# монеты 6×20 + бонус 50 = 170 ≥ 80
monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99)
for _ in range(6):
_done(client, _create_task(client, priority=9, estimated_minutes=480))
assert client.post("/api/garden/shop/buy", json={"item_key": "lantern"}).status_code == 200
assert client.post("/api/garden/shop/buy", json={"item_key": "lantern"}).status_code == 409
def test_shop_expansion_lock_and_order(client: TestClient, monkeypatch: Any) -> None:
# Уровень 1 < 3 — замок
resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"})
assert resp.status_code == 403
assert "3" in resp.json()["detail"]
# До L3 нужен 300 XP: 8 задач по 40 = 320; монеты 8×20 + бонусы 50+75 = 285
monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99)
for _ in range(8):
_done(client, _create_task(client, priority=9, estimated_minutes=480))
state = client.get("/api/garden").json()
assert state["level"] == 3
resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"})
assert resp.status_code == 200
# ТЗ 0.37: старт 24×12, первое расширение возвращает прежний старт 32×20
assert client.get("/api/garden").json()["grid"] == {"cols": 32, "rows": 20}
# Повторное расширение_1 (следующее — expansion_2) — 409
resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"})
assert resp.status_code == 409
# Второе расширение открывается на уровне 12 (ТЗ 0.37) — пока замок
resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_2"})
assert resp.status_code == 403
assert "12" in resp.json()["detail"]
# --- Авторазмещение и виды ---
def test_species_unlock_and_placement(client: TestClient) -> None:
state = client.get("/api/garden").json()
# Уровень 1: только стартовые виды, cactus закрыт (premium)
keys = [s["key"] for s in state["species"] if s["unlocked"]]
assert "ph-flower" in keys and "ph-cactus" not in keys
_done(client, _create_task(client))
state = client.get("/api/garden").json()
plants = [i for i in state["items"] if i["kind"] == "plant"]
assert len(plants) == 1
# Размещение по спирали — вне footprint домика и в границах сетки
assert plants[0]["item_key"] in keys
assert 0 <= plants[0]["x"] < state["grid"]["cols"]
assert 0 <= plants[0]["y"] < state["grid"]["rows"]
def test_spiral_position_skips_occupied() -> None:
occupied: set[tuple[int, int]] = set()
first = spiral_position(occupied, 24, 12)
assert first == (9, 3) # радиус 3 от центра — первый шаг спирали
occupied.add(first)
second = spiral_position(occupied, 24, 12)
assert second not in occupied
def test_reposition_out_of_bounds(client: TestClient) -> None:
"""ТЗ 0.37: сад стал компактнее — предметы за границей переставляются
в пределы сетки при первом заходе (ничего не теряется, идемпотентно)."""
from app.services.garden import reposition_out_of_bounds
session = _test_session_factory()
try:
# позиция за новой стартовой границей 24×12 (как в старых садах)
session.add(
GardenItem(user_id=AUTH_USER["user_id"], kind="plant", item_key="ph-flower", x=26, y=15)
)
session.commit()
item_id = session.scalars(select(GardenItem.id).where(GardenItem.x == 26)).one()
finally:
session.close()
state = client.get("/api/garden").json()
assert state["grid"] == {"cols": 24, "rows": 12}
item = next(i for i in state["items"] if i["id"] == item_id)
assert 0 <= item["x"] < 24 and 0 <= item["y"] < 12
# идемпотентность: повторный вызов — 0
session = _test_session_factory()
try:
assert reposition_out_of_bounds(session, AUTH_USER["user_id"]) == 0
finally:
session.close()
def test_level_bonus_helper() -> None:
assert level_bonus_coins(2) == 50
assert coins_for_xp(40) == 20
# ТЗ 0.37: лестница расширений сокращена до двух шагов
assert set(EXPANSIONS) == {"expansion_1", "expansion_2"}