"""Сад-сцена (ТЗ 3.13): монеты, магазин, апгрейды, перемещение, авторазмещение."""
from typing import Any
from fastapi.testclient import TestClient
from app.services.garden import (
DECORATIONS,
EXPANSIONS,
coins_for_xp,
level_bonus_coins,
spiral_position,
)
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()
assert state["balance"] == coins_for_xp(int(earned["xp"]))
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 за задачи + бонус 25×2 = 110
assert client.get("/api/garden").json()["balance"] == 110
# --- Перемещение ---
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)
resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade")
assert resp.status_code == 200
assert resp.json()["stage"] == 1
assert resp.json()["balance"] == 0
# Вторая стадия — не хватает (50 > 0)
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"]
assert balance_after == 40 - 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
assert client.get("/api/garden").json()["grid"]["cols"] > 32
# Внепорядковое расширение (expansion_2 ещё следующий, 3 просит) — 409
resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_3"})
assert resp.status_code == 409
# --- Авторазмещение и виды ---
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, 32, 20)
assert first == (13, 7) # радиус 3 от центра — первый шаг спирали
occupied.add(first)
second = spiral_position(occupied, 32, 20)
assert second not in occupied
def test_level_bonus_helper() -> None:
assert level_bonus_coins(2) == 50
assert coins_for_xp(40) == 20
assert set(EXPANSIONS) == {"expansion_1", "expansion_2", "expansion_3"}