"""Тесты M4: прогноз времени по истории, бюджет, режим «3 варианта»."""
from typing import Any
from fastapi.testclient import TestClient
from app.models import Task
from app.services.predict import pick_options, predict_minutes
from tests.conftest import _test_session_factory
def test_session() -> Any:
return _test_session_factory()
def _db_task(task_id: int) -> Task:
session = _test_session_factory()
task = session.get(Task, task_id)
session.close()
assert task is not None
return task
def _mkdone(
client: TestClient,
title: str,
actual: int,
project_id: int | None = None,
tag_id: int | None = None,
) -> None:
tid = client.post("/api/tasks", json={"title": title}).json()["id"]
patch: dict[str, Any] = {"status": "done", "actual_minutes": actual}
if project_id:
patch["project_id"] = project_id
if tag_id:
patch["tag_ids"] = [tag_id]
assert client.patch(f"/api/tasks/{tid}", json=patch).status_code == 200
def test_predict_median_by_project(client: TestClient) -> None:
pid = client.post("/api/projects", json={"name": "Кухня"}).json()["id"]
_mkdone(client, "Помыть посуду", 20, project_id=pid)
_mkdone(client, "Протереть стол", 30, project_id=pid)
_mkdone(client, "Разобрать ящик", 40, project_id=pid)
fresh = client.post("/api/tasks", json={"title": "Убрать кухню"}).json()["id"]
client.patch(f"/api/tasks/{fresh}", json={"project_id": pid})
predicted = predict_minutes(test_session(), _db_task(fresh))
assert predicted == 30 # медиана 20, 30, 40
def test_predict_none_without_history(client: TestClient) -> None:
fresh = client.post("/api/tasks", json={"title": "Новое"}).json()["id"]
assert predict_minutes(test_session(), _db_task(fresh)) is None
def test_predict_endpoint_and_manual_fields(client: TestClient) -> None:
tid = client.post("/api/tasks", json={"title": "Задача с бюджетом"}).json()["id"]
updated = client.patch(
f"/api/tasks/{tid}",
json={"budget_money": 5000, "cost_estimate_money": 3500, "actual_minutes": 90},
).json()
assert updated["budget_money"] == 5000
assert updated["cost_estimate_money"] == 3500
assert updated["actual_minutes"] == 90
# нет истории — прогноз не появился, эндпоинт отвечает задачей с null
predicted = client.post(f"/api/tasks/{tid}/predict").json()
assert predicted["estimated_minutes"] is None
def test_pick_options_fit_time_and_diversity(client: TestClient) -> None:
p1 = client.post("/api/projects", json={"name": "Дом"}).json()["id"]
p2 = client.post("/api/projects", json={"name": "Работа"}).json()["id"]
def approve(title: str, minutes: int | None, project_id: int | None) -> None:
tid = client.post("/api/tasks", json={"title": title}).json()["id"]
patch: dict[str, Any] = {}
if minutes is not None:
patch["estimated_minutes"] = minutes
if project_id is not None:
patch["project_id"] = project_id
client.patch(f"/api/tasks/{tid}", json=patch)
client.post(f"/api/tasks/{tid}/approve")
approve("Мелочь 30м", 30, p1)
approve("Средняя 60м", 60, p2)
approve("Большая 300м", 300, None)
approve("Без оценки", None, p1)
# приостановленный проект исключается
paused = client.post("/api/projects", json={"name": "Заморожено"}).json()["id"]
client.patch(f"/api/projects/{paused}", json={"relevance_status": "paused"})
approve("Из замороженного", 15, paused)
options = pick_options(test_session(), 90)
titles = [t.title for t in options]
assert len(options) <= 3
assert "Мелочь 30м" in titles
assert "Средняя 60м" in titles
assert "Большая 300м" not in titles # не влезает в 90 минут
assert "Из замороженного" not in titles # проект неактивен
# разнообразие: не более одной задачи на проект
projects_used = [t.project_id for t in options]
assert len(projects_used) == len(set(projects_used))
def test_pick_options_skips_done_and_cancelled(client: TestClient) -> None:
tid = client.post("/api/tasks", json={"title": "Завершённая"}).json()["id"]
client.patch(f"/api/tasks/{tid}", json={"status": "done"})
client.post(f"/api/tasks/{tid}/approve")
options = pick_options(test_session(), 600)
assert all(t.id != tid for t in options)
def test_suggest_endpoint(client: TestClient) -> None:
resp = client.post("/api/tasks/suggest", json={"available_minutes": 120})
assert resp.status_code == 200
assert len(resp.json()) <= 3
# валидация времени
assert client.post("/api/tasks/suggest", json={"available_minutes": 0}).status_code == 422
def test_proposal_estimate_applied(client: TestClient, monkeypatch: Any) -> None:
def fake_propose(
self: Any, title: str, description: str, tag_names: list[str], project_names: list[str]
) -> dict[str, Any]:
return {
"tags": [],
"project": None,
"new_project": False,
"priority": 7,
"estimated_minutes": 45,
}
monkeypatch.setattr("app.services.detailing.DetailingService.propose", fake_propose)
tid = client.post("/api/tasks", json={"title": "Что-то на 45 минут"}).json()["id"]
approved = client.post(f"/api/tasks/{tid}/approve", json={"apply_proposal": True}).json()
assert approved["estimated_minutes"] == 45