"""Правила напоминаний (ТЗ 3.21, 0.89).
Здесь только чистые функции: «сегодня» — аргумент, часов и базы нет. Поэтому
проверяем календарные границы всеми способами, которые ломаются на живых данных:
срок перенесли, задача просрочена давно, регулярная выпала на сегодня, а тихое
окно пересекает полночь.
"""
from datetime import UTC, date, datetime, timedelta
from typing import Any
from app.models import Task
from app.services import reminders
TODAY = date(2026, 10, 10)
def _task(task_id: int = 1, **fields: Any) -> Task:
"""Задача в памяти: правила не ходят в базу, ей хватает атрибутов.
Статус и `created_at` задаём руками: у модели это `default`, который
срабатывает на вставке, а до неё объект ещё «пустой».
"""
fields.setdefault("status", "to_do")
task = Task(id=task_id, user_id="1", title="Задача", **fields)
task.created_at = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
return task
def _kinds(items: list[reminders.Reminder]) -> list[str]:
return [item.kind for item in items]
# --- Строгий срок -------------------------------------------------------------
def test_deadline_reminds_day_before_today_and_overdue() -> None:
tomorrow = reminders.deadline_reminders(
_task(deadline_date=TODAY + timedelta(days=1)), TODAY
)
assert _kinds(tomorrow) == [reminders.KIND_DUE_TOMORROW]
due = reminders.deadline_reminders(_task(deadline_date=TODAY), TODAY)
assert _kinds(due) == [reminders.KIND_DUE_TODAY]
late = reminders.deadline_reminders(_task(deadline_date=TODAY - timedelta(days=40)), TODAY)
assert _kinds(late) == [reminders.KIND_OVERDUE]
def test_deadline_keeps_silence_far_ahead() -> None:
"""За неделю до срока напоминать нечего — иначе напоминание превращается в шум."""
assert reminders.deadline_reminders(_task(deadline_date=TODAY + timedelta(days=7)), TODAY) == []
def test_soft_deadline_never_reminds() -> None:
"""«В течение недели/месяца/года» — периода без дня отсчёта достаточно, чтобы молчать."""
assert reminders.deadline_reminders(_task(deadline_period="week"), TODAY) == []
assert reminders.deadline_reminders(_task(deadline_period="year"), TODAY) == []
def test_overdue_fires_once_per_deadline_date() -> None:
"""Просрочка звучит один раз: ключ привязан к дате срока, а не к «сегодня»."""
task = _task(deadline_date=TODAY - timedelta(days=3))
first = reminders.deadline_reminders(task, TODAY)[0]
later = reminders.deadline_reminders(task, TODAY + timedelta(days=5))[0]
assert first.ref_key == later.ref_key
# перенесли срок — напоминание о новом сроке законное, ключ другой
task.deadline_date = TODAY + timedelta(days=1)
moved = reminders.deadline_reminders(task, TODAY)[0]
assert moved.ref_key != first.ref_key
def test_closed_and_deferred_tasks_are_silent() -> None:
"""`done`/`cancelled` — работа кончилась, `deferred` — владелец сам отложил."""
for status in ("done", "cancelled", "deferred"):
task = _task(status=status, deadline_date=TODAY)
assert reminders.task_reminders(task, TODAY) == [], status
# --- Ритм регулярной задачи ---------------------------------------------------
def test_rhythm_reminds_on_its_own_day() -> None:
"""«Пора: зарядка» — в день, который правило повторения и так назначило."""
daily = _task(task_type="recurring", recur_kind="interval", recur_interval_days=1)
assert _kinds(reminders.rhythm_reminders(daily, TODAY)) == [reminders.KIND_RHYTHM]
# ключ — задача и дата: завтра это уже другое напоминание, а сегодняшнее не повторится
assert reminders.rhythm_reminders(daily, TODAY)[0].ref_key == f"{daily.id}:{TODAY.isoformat()}"
assert reminders.rhythm_reminders(daily, TODAY + timedelta(days=1))[0].ref_key != (
f"{daily.id}:{TODAY.isoformat()}"
)
def test_rhythm_keys_of_two_tasks_on_the_same_day_differ() -> None:
"""Две регулярные, выпавшие на сегодня, — два напоминания, а не одно.
Id задачи в ключе обязателен: без него уникальность `push_deliveries` по
(пользователь, вид, ключ) съедала второе «Пора» — за день приходило одно
на пользователя, сколько бы регулярных ни совпало (0.91).
"""
first = _task(task_id=1, task_type="recurring", recur_kind="interval", recur_interval_days=1)
second = _task(task_id=2, task_type="recurring", recur_kind="interval", recur_interval_days=1)
keys = {r.ref_key for t in (first, second) for r in reminders.rhythm_reminders(t, TODAY)}
assert len(keys) == 2
def test_regular_task_has_single_reason() -> None:
"""У регулярной повод один — ритм, даже если дата у неё есть.
С 0.91 клон получает `deadline_date` = дату следующего экземпляра (3.5).
Дедлайновые правила его не касаются: иначе в день срока приходило бы два
уведомления об одном деле — «Срок сегодня» и «Пора».
"""
clone = _task(task_type="recurring", recur_kind="interval", recur_interval_days=1,
deadline_date=TODAY)
assert reminders.deadline_reminders(clone, TODAY) == []
assert _kinds(reminders.task_reminders(clone, TODAY)) == [reminders.KIND_RHYTHM]
def test_rhythm_skips_other_days() -> None:
"""Каждые три дня от 1 сентября: 10 октября — да, 9-е — нет."""
every_three = _task(task_type="recurring", recur_kind="interval", recur_interval_days=3)
assert reminders.rhythm_reminders(every_three, TODAY) != []
assert reminders.rhythm_reminders(every_three, TODAY - timedelta(days=1)) == []
def test_rhythm_ignores_plain_and_ruleless_tasks() -> None:
assert reminders.rhythm_reminders(_task(), TODAY) == []
assert reminders.rhythm_reminders(_task(task_type="recurring", recur_kind=None), TODAY) == []
# Прежний тест «одна задача может дать оба повода» ушёл в 0.91: у регулярной
# со сроком два уведомления в один день — это то, что владелец просил убрать
# (см. test_regular_task_has_single_reason выше).
# --- Тихие часы ---------------------------------------------------------------
def test_quiet_hours_within_same_day() -> None:
window = "13:00-15:00"
assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 14, 0))
assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 15, 0))
assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 12, 59))
def test_quiet_hours_across_midnight() -> None:
"""Окно 22:00–09:00 — то, ради чего тихие часы и заводят."""
window = "22:00-09:00"
assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 23, 30))
assert reminders.in_quiet_hours(window, datetime(2026, 10, 10, 8, 59))
assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 9, 0))
assert not reminders.in_quiet_hours(window, datetime(2026, 10, 10, 21, 59))
def test_broken_or_empty_quiet_hours_disable_the_window() -> None:
"""Битая настройка — не инвариант: опечатка выключает окно, а не ломает планировщик."""
for value in (None, "", "22:00", "22:00-", "25:00-09:00", "вечер-утро", "22:00-22:00"):
assert reminders.parse_quiet_hours(value) is None or not reminders.in_quiet_hours(
value, datetime(2026, 10, 10, 23, 0)
), value
# --- Сводка -------------------------------------------------------------------
def test_summary_window_is_morning_to_early_afternoon() -> None:
assert not reminders.summary_due(datetime(2026, 10, 10, 8, 59))
assert reminders.summary_due(datetime(2026, 10, 10, 9, 0))
assert reminders.summary_due(datetime(2026, 10, 10, 13, 59))
assert not reminders.summary_due(datetime(2026, 10, 10, 14, 0))
def test_summary_counts_what_needs_attention() -> None:
tasks = [
_task(1, deadline_date=TODAY - timedelta(days=2)),
_task(2, deadline_date=TODAY),
_task(3, status="done", accept_state="pending"), # работа агента ждёт приёмки
_task(4, deadline_date=TODAY, status="done"), # сделана — в счёт не идёт
_task(5, deadline_period="week"),
]
assert reminders.summary_counts(tasks, TODAY) == {"overdue": 1, "today": 1, "review": 1}
def test_summary_counts_zero_when_nothing_to_say() -> None:
"""Ноль по всем счётчикам — планировщик молчит (и в журнал не пишет)."""
assert reminders.summary_counts([_task(deadline_date=TODAY + timedelta(days=5))], TODAY) == {
"overdue": 0,
"today": 0,
"review": 0,
}