Newer
Older
gnexus-tasks / backend / tests / test_reminders.py
"""Правила напоминаний (ТЗ 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,
    }