Calendar reminders use bot.state (SQLite KV) for persistence across restarts. Supports one-shot at specific date/time and yearly recurring reminders with leap day handling. Restored automatically on connect via 001 event handler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1160 lines
36 KiB
Python
1160 lines
36 KiB
Python
"""Tests for the remind plugin."""
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from derp.irc import Message
|
|
|
|
# plugins/ is not a Python package -- load the module from file path
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"plugins.remind", Path(__file__).resolve().parent.parent / "plugins" / "remind.py",
|
|
)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
sys.modules[_spec.name] = _mod
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
from plugins.remind import ( # noqa: E402
|
|
_by_user,
|
|
_calendar,
|
|
_cleanup,
|
|
_delete_saved,
|
|
_format_duration,
|
|
_get_tz,
|
|
_make_id,
|
|
_next_yearly,
|
|
_parse_date,
|
|
_parse_duration,
|
|
_parse_time,
|
|
_remind_once,
|
|
_remind_repeat,
|
|
_reminders,
|
|
_restore,
|
|
_save,
|
|
_schedule_at,
|
|
cmd_remind,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeState:
|
|
"""In-memory stand-in for bot.state (SQLite KV)."""
|
|
|
|
def __init__(self):
|
|
self._store: dict[str, dict[str, str]] = {}
|
|
|
|
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
|
return self._store.get(plugin, {}).get(key, default)
|
|
|
|
def set(self, plugin: str, key: str, value: str) -> None:
|
|
self._store.setdefault(plugin, {})[key] = value
|
|
|
|
def delete(self, plugin: str, key: str) -> bool:
|
|
try:
|
|
del self._store[plugin][key]
|
|
return True
|
|
except KeyError:
|
|
return False
|
|
|
|
def keys(self, plugin: str) -> list[str]:
|
|
return sorted(self._store.get(plugin, {}).keys())
|
|
|
|
|
|
class _FakeBot:
|
|
"""Minimal bot stand-in that captures sent/replied messages."""
|
|
|
|
def __init__(self, *, tz: str = "UTC"):
|
|
self.sent: list[tuple[str, str]] = []
|
|
self.replied: list[str] = []
|
|
self.config: dict = {"bot": {"timezone": tz}}
|
|
self.state = _FakeState()
|
|
|
|
async def send(self, target: str, text: str) -> None:
|
|
self.sent.append((target, text))
|
|
|
|
async def reply(self, message, text: str) -> None:
|
|
self.replied.append(text)
|
|
|
|
|
|
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
|
|
"""Create a channel PRIVMSG."""
|
|
return Message(
|
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
|
command="PRIVMSG", params=[target, text], tags={},
|
|
)
|
|
|
|
|
|
def _pm(text: str, nick: str = "alice") -> Message:
|
|
"""Create a private PRIVMSG (target = bot nick)."""
|
|
return Message(
|
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
|
command="PRIVMSG", params=["botname", text], tags={},
|
|
)
|
|
|
|
|
|
def _clear() -> None:
|
|
"""Reset global module state between tests."""
|
|
for entry in _reminders.values():
|
|
task = entry[0]
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
_reminders.clear()
|
|
_by_user.clear()
|
|
_calendar.clear()
|
|
|
|
|
|
async def _run_cmd(bot, msg):
|
|
"""Run cmd_remind and cancel any spawned tasks afterward."""
|
|
await cmd_remind(bot, msg)
|
|
# Yield so tasks can start
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
async def _run_cmd_and_cleanup(bot, msg):
|
|
"""Run cmd_remind, yield, then cancel all spawned tasks."""
|
|
await cmd_remind(bot, msg)
|
|
await asyncio.sleep(0)
|
|
for entry in list(_reminders.values()):
|
|
if entry[0] is not None and not entry[0].done():
|
|
entry[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers: _parse_duration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseDurationRawSeconds:
|
|
def test_positive_integer(self):
|
|
assert _parse_duration("60") == 60
|
|
|
|
def test_zero_returns_none(self):
|
|
assert _parse_duration("0") is None
|
|
|
|
def test_negative_returns_none(self):
|
|
assert _parse_duration("-5") is None
|
|
|
|
def test_large_value(self):
|
|
assert _parse_duration("86400") == 86400
|
|
|
|
|
|
class TestParseDurationSpecs:
|
|
def test_minutes(self):
|
|
assert _parse_duration("5m") == 300
|
|
|
|
def test_hours_and_minutes(self):
|
|
assert _parse_duration("1h30m") == 5400
|
|
|
|
def test_days(self):
|
|
assert _parse_duration("2d") == 172800
|
|
|
|
def test_seconds_suffix(self):
|
|
assert _parse_duration("90s") == 90
|
|
|
|
def test_full_combo(self):
|
|
assert _parse_duration("1d12h30m15s") == 131415
|
|
|
|
|
|
class TestParseDurationInvalid:
|
|
def test_empty_string(self):
|
|
assert _parse_duration("") is None
|
|
|
|
def test_letters_only(self):
|
|
assert _parse_duration("abc") is None
|
|
|
|
def test_all_zeros(self):
|
|
assert _parse_duration("0m0s") is None
|
|
|
|
|
|
class TestParseDurationEdgeCases:
|
|
def test_uppercase_works(self):
|
|
assert _parse_duration("5M") == 300
|
|
|
|
def test_hours_with_zero_minutes(self):
|
|
assert _parse_duration("1h0m") == 3600
|
|
|
|
def test_all_zeros_except_one_second(self):
|
|
assert _parse_duration("0d0h0m1s") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers: _format_duration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFormatDuration:
|
|
def test_minutes_and_seconds(self):
|
|
assert _format_duration(90) == "1m30s"
|
|
|
|
def test_exact_hour(self):
|
|
assert _format_duration(3600) == "1h"
|
|
|
|
def test_exact_day(self):
|
|
assert _format_duration(86400) == "1d"
|
|
|
|
def test_zero(self):
|
|
assert _format_duration(0) == "0s"
|
|
|
|
def test_full_combo(self):
|
|
assert _format_duration(90061) == "1d1h1m1s"
|
|
|
|
def test_seconds_only(self):
|
|
assert _format_duration(45) == "45s"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers: _make_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMakeId:
|
|
def test_returns_six_char_hex(self):
|
|
rid = _make_id("user", "check oven")
|
|
assert len(rid) == 6
|
|
assert all(c in "0123456789abcdef" for c in rid)
|
|
|
|
def test_different_inputs_differ(self):
|
|
rid1 = _make_id("alice", "task one")
|
|
rid2 = _make_id("bob", "task two")
|
|
assert rid1 != rid2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCleanup:
|
|
def test_removes_from_both_structures(self):
|
|
_clear()
|
|
_reminders["abc123"] = (None, "#ch", "alice", "label", "12:00", False)
|
|
_by_user[("#ch", "alice")] = ["abc123"]
|
|
|
|
_cleanup("abc123", "#ch", "alice")
|
|
|
|
assert "abc123" not in _reminders
|
|
assert ("#ch", "alice") not in _by_user
|
|
|
|
def test_removes_single_entry_from_multi(self):
|
|
_clear()
|
|
_reminders["aaa"] = (None, "#ch", "alice", "", "12:00", False)
|
|
_reminders["bbb"] = (None, "#ch", "alice", "", "12:00", False)
|
|
_by_user[("#ch", "alice")] = ["aaa", "bbb"]
|
|
|
|
_cleanup("aaa", "#ch", "alice")
|
|
|
|
assert "aaa" not in _reminders
|
|
assert _by_user[("#ch", "alice")] == ["bbb"]
|
|
|
|
def test_missing_rid_no_error(self):
|
|
_clear()
|
|
_cleanup("nonexistent", "#ch", "alice")
|
|
|
|
def test_missing_user_key_no_error(self):
|
|
_clear()
|
|
_reminders["abc"] = (None, "#ch", "alice", "", "12:00", False)
|
|
|
|
_cleanup("abc", "#ch", "bob") # different nick, user key absent
|
|
|
|
assert "abc" not in _reminders
|
|
|
|
def test_clears_calendar_set(self):
|
|
_clear()
|
|
_reminders["cal01"] = (None, "#ch", "alice", "", "12:00", False)
|
|
_by_user[("#ch", "alice")] = ["cal01"]
|
|
_calendar.add("cal01")
|
|
|
|
_cleanup("cal01", "#ch", "alice")
|
|
|
|
assert "cal01" not in _calendar
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _remind_once
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRemindOnce:
|
|
def test_fires_metadata_and_label(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "once01"
|
|
task = asyncio.create_task(
|
|
_remind_once(bot, rid, "#ch", "alice", "check oven", 0, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False)
|
|
_by_user[("#ch", "alice")] = [rid]
|
|
await task
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.sent) == 2
|
|
assert "alice: reminder #once01" in bot.sent[0][1]
|
|
assert "12:00:00 UTC" in bot.sent[0][1]
|
|
assert bot.sent[1] == ("#ch", "check oven")
|
|
assert "once01" not in _reminders
|
|
|
|
def test_empty_label_sends_one_line(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "once02"
|
|
task = asyncio.create_task(
|
|
_remind_once(bot, rid, "#ch", "bob", "", 0, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False)
|
|
_by_user[("#ch", "bob")] = [rid]
|
|
await task
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.sent) == 1
|
|
|
|
def test_cancellation_cleans_up(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "once03"
|
|
task = asyncio.create_task(
|
|
_remind_once(bot, rid, "#ch", "alice", "text", 9999, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False)
|
|
_by_user[("#ch", "alice")] = [rid]
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.sent) == 0
|
|
assert "once03" not in _reminders
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _remind_repeat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRemindRepeat:
|
|
def test_fires_at_least_once(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "rpt01"
|
|
task = asyncio.create_task(
|
|
_remind_repeat(bot, rid, "#ch", "alice", "hydrate", 0, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True)
|
|
_by_user[("#ch", "alice")] = [rid]
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.sent) >= 2 # at least one fire (metadata + label)
|
|
assert any("rpt01" in t for _, t in bot.sent)
|
|
assert "rpt01" not in _reminders
|
|
|
|
def test_cancellation_cleans_up(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "rpt02"
|
|
task = asyncio.create_task(
|
|
_remind_repeat(bot, rid, "#ch", "bob", "stretch", 9999, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True)
|
|
_by_user[("#ch", "bob")] = [rid]
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.sent) == 0
|
|
assert "rpt02" not in _reminders
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: usage / errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindUsage:
|
|
def test_no_args_shows_usage(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind")))
|
|
assert "Usage:" in bot.replied[0]
|
|
|
|
def test_invalid_duration(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind xyz some text")))
|
|
assert "Invalid duration" in bot.replied[0]
|
|
|
|
def test_every_no_args(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind every")))
|
|
assert "Invalid duration" in bot.replied[0]
|
|
|
|
def test_every_invalid_duration(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind every abc")))
|
|
assert "Invalid duration" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: one-shot creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindOneshot:
|
|
def test_creates_with_duration(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind 5m check the oven"))
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.replied) == 1
|
|
assert "set (5m)" in bot.replied[0]
|
|
assert "#" in bot.replied[0]
|
|
|
|
def test_no_label(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind 5m"))
|
|
|
|
asyncio.run(inner())
|
|
assert "set (5m)" in bot.replied[0]
|
|
|
|
def test_stores_in_tracking(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s task"))
|
|
assert len(_reminders) == 1
|
|
entry = next(iter(_reminders.values()))
|
|
assert entry[1] == "#test" # target
|
|
assert entry[2] == "alice" # nick
|
|
assert entry[3] == "task" # label
|
|
assert entry[5] is False # not repeating
|
|
assert ("#test", "alice") in _by_user
|
|
# cleanup
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_days_duration(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind 2d12h renew cert"))
|
|
|
|
asyncio.run(inner())
|
|
assert "set (2d12h)" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: repeating creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindRepeat:
|
|
def test_creates_repeating(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind every 1h drink water"))
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.replied) == 1
|
|
assert "every 1h" in bot.replied[0]
|
|
|
|
def test_repeating_stores_flag(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind every 30m stretch"))
|
|
entry = next(iter(_reminders.values()))
|
|
assert entry[5] is True # repeating flag
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_repeating_no_label(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind every 1h"))
|
|
|
|
asyncio.run(inner())
|
|
assert "every 1h" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindList:
|
|
def test_empty_list(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind list")))
|
|
assert "No active reminders" in bot.replied[0]
|
|
|
|
def test_shows_active(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s task"))
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg("!remind list"))
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
assert "Reminders:" in bot.replied[0]
|
|
assert "#" in bot.replied[0]
|
|
|
|
def test_shows_repeat_tag(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind every 9999s task"))
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg("!remind list"))
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
assert "(repeat)" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: cancel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindCancel:
|
|
def _extract_rid(self, reply: str) -> str:
|
|
"""Extract reminder ID from 'Reminder #abc123 set (...)' reply."""
|
|
return reply.split("#")[1].split(" ")[0]
|
|
|
|
def test_cancel_valid(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s task"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg(f"!remind cancel {rid}"))
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
assert "Cancelled" in bot.replied[0]
|
|
|
|
def test_cancel_with_hash_prefix(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s task"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg(f"!remind cancel #{rid}"))
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
assert "Cancelled" in bot.replied[0]
|
|
|
|
def test_cancel_wrong_user(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s secret"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg(f"!remind cancel {rid}", nick="eve"))
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
assert "No active reminder" in bot.replied[0]
|
|
|
|
def test_cancel_nonexistent(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind cancel ffffff")))
|
|
assert "No active reminder" in bot.replied[0]
|
|
|
|
def test_cancel_no_id(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
asyncio.run(cmd_remind(bot, _msg("!remind cancel")))
|
|
assert "Usage:" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind: target routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindTarget:
|
|
def test_channel_target(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind 9999s task", target="#ops"))
|
|
entry = next(iter(_reminders.values()))
|
|
assert entry[1] == "#ops"
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_pm_uses_nick(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _pm("!remind 9999s task"))
|
|
entry = next(iter(_reminders.values()))
|
|
assert entry[1] == "alice" # nick, not "botname"
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar helpers: _parse_date
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseDate:
|
|
def test_valid_iso_date(self):
|
|
assert _parse_date("2027-02-15") == date(2027, 2, 15)
|
|
|
|
def test_invalid_format_no_dashes(self):
|
|
assert _parse_date("20270215") is None
|
|
|
|
def test_invalid_month(self):
|
|
assert _parse_date("2027-13-01") is None
|
|
|
|
def test_invalid_day(self):
|
|
assert _parse_date("2027-02-30") is None
|
|
|
|
def test_leap_day_valid(self):
|
|
assert _parse_date("2028-02-29") == date(2028, 2, 29)
|
|
|
|
def test_leap_day_invalid_year(self):
|
|
assert _parse_date("2027-02-29") is None
|
|
|
|
def test_empty_string(self):
|
|
assert _parse_date("") is None
|
|
|
|
def test_garbage(self):
|
|
assert _parse_date("not-a-date") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar helpers: _parse_time
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseTime:
|
|
def test_valid_noon(self):
|
|
assert _parse_time("12:00") == (12, 0)
|
|
|
|
def test_valid_midnight(self):
|
|
assert _parse_time("00:00") == (0, 0)
|
|
|
|
def test_valid_end_of_day(self):
|
|
assert _parse_time("23:59") == (23, 59)
|
|
|
|
def test_invalid_hour(self):
|
|
assert _parse_time("24:00") is None
|
|
|
|
def test_invalid_minute(self):
|
|
assert _parse_time("12:60") is None
|
|
|
|
def test_no_colon(self):
|
|
assert _parse_time("1200") is None
|
|
|
|
def test_single_digit(self):
|
|
assert _parse_time("9:00") is None
|
|
|
|
def test_empty(self):
|
|
assert _parse_time("") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar helpers: _next_yearly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNextYearly:
|
|
def test_future_this_year(self):
|
|
tz = ZoneInfo("UTC")
|
|
now = datetime.now(tz)
|
|
# Pick a month in the future
|
|
future_month = now.month + 1 if now.month < 12 else 1
|
|
future_year = now.year if now.month < 12 else now.year + 1
|
|
result = _next_yearly(future_month, 15, 12, 0, tz)
|
|
assert result.year == future_year
|
|
assert result.month == future_month
|
|
assert result.day == 15
|
|
|
|
def test_past_this_year_rolls_to_next(self):
|
|
tz = ZoneInfo("UTC")
|
|
now = datetime.now(tz)
|
|
# Pick January 1 -- almost always in the past
|
|
result = _next_yearly(1, 1, 0, 0, tz)
|
|
assert result.year == now.year + 1
|
|
assert result.month == 1
|
|
assert result.day == 1
|
|
|
|
def test_leap_day_clamped_in_non_leap_year(self):
|
|
tz = ZoneInfo("UTC")
|
|
# 2027 is not a leap year
|
|
result = _next_yearly(2, 29, 12, 0, tz)
|
|
# Should clamp to 28 if the computed year is non-leap
|
|
if result.year % 4 != 0:
|
|
assert result.day == 28
|
|
else:
|
|
assert result.day == 29
|
|
|
|
def test_timezone_aware(self):
|
|
tz = ZoneInfo("Europe/Brussels")
|
|
result = _next_yearly(6, 15, 14, 30, tz)
|
|
assert result.tzinfo == tz
|
|
assert result.hour == 14
|
|
assert result.minute == 30
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar helpers: _get_tz
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetTz:
|
|
def test_default_utc(self):
|
|
bot = _FakeBot()
|
|
bot.config = {"bot": {}}
|
|
assert _get_tz(bot) == ZoneInfo("UTC")
|
|
|
|
def test_configured_timezone(self):
|
|
bot = _FakeBot(tz="Europe/Brussels")
|
|
assert _get_tz(bot) == ZoneInfo("Europe/Brussels")
|
|
|
|
def test_invalid_timezone_falls_back(self):
|
|
bot = _FakeBot()
|
|
bot.config = {"bot": {"timezone": "Not/A/Timezone"}}
|
|
assert _get_tz(bot) == ZoneInfo("UTC")
|
|
|
|
def test_missing_bot_section(self):
|
|
bot = _FakeBot()
|
|
bot.config = {}
|
|
assert _get_tz(bot) == ZoneInfo("UTC")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind at: calendar one-shot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindAt:
|
|
def _extract_rid(self, reply: str) -> str:
|
|
return reply.split("#")[1].split(" ")[0]
|
|
|
|
def test_valid_future_date(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} deploy release"))
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.replied) == 1
|
|
assert "set (at" in bot.replied[0]
|
|
assert "deploy release" not in bot.replied[0] # label not in confirmation
|
|
|
|
def test_past_date_rejected(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind at 2020-01-01 old stuff"))
|
|
|
|
asyncio.run(inner())
|
|
assert "past" in bot.replied[0].lower()
|
|
|
|
def test_default_time_noon(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} no time given"))
|
|
|
|
asyncio.run(inner())
|
|
assert "12:00" in bot.replied[0]
|
|
|
|
def test_with_explicit_time(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} 14:30 deploy"))
|
|
|
|
asyncio.run(inner())
|
|
assert "14:30" in bot.replied[0]
|
|
|
|
def test_stores_in_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg(f"!remind at {future} persist me"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
raw = bot.state.get("remind", rid)
|
|
assert raw is not None
|
|
data = json.loads(raw)
|
|
assert data["type"] == "at"
|
|
assert data["nick"] == "alice"
|
|
assert data["label"] == "persist me"
|
|
assert rid in _calendar
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_invalid_date_format(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind at not-a-date some text"))
|
|
|
|
asyncio.run(inner())
|
|
assert "Invalid date" in bot.replied[0]
|
|
|
|
def test_no_args_shows_usage(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind at"))
|
|
|
|
asyncio.run(inner())
|
|
assert "Usage:" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_remind yearly: recurring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCmdRemindYearly:
|
|
def _extract_rid(self, reply: str) -> str:
|
|
return reply.split("#")[1].split(" ")[0]
|
|
|
|
def test_valid_creation(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 06-15 happy anniversary"))
|
|
|
|
asyncio.run(inner())
|
|
assert len(bot.replied) == 1
|
|
assert "yearly 06-15" in bot.replied[0]
|
|
|
|
def test_invalid_date(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 13-01 bad month"))
|
|
|
|
asyncio.run(inner())
|
|
assert "Invalid date" in bot.replied[0]
|
|
|
|
def test_invalid_day(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 04-31 too many days"))
|
|
|
|
asyncio.run(inner())
|
|
assert "Invalid date" in bot.replied[0]
|
|
|
|
def test_stores_in_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg("!remind yearly 02-14 valentines"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
raw = bot.state.get("remind", rid)
|
|
assert raw is not None
|
|
data = json.loads(raw)
|
|
assert data["type"] == "yearly"
|
|
assert data["month_day"] == "02-14"
|
|
assert data["nick"] == "alice"
|
|
assert rid in _calendar
|
|
for e in _reminders.values():
|
|
e[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_with_explicit_time(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 12-25 09:00 merry christmas"))
|
|
|
|
asyncio.run(inner())
|
|
assert "yearly 12-25" in bot.replied[0]
|
|
|
|
def test_no_args_shows_usage(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly"))
|
|
|
|
asyncio.run(inner())
|
|
assert "Usage:" in bot.replied[0]
|
|
|
|
def test_leap_day_allowed(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 02-29 leap day birthday"))
|
|
|
|
asyncio.run(inner())
|
|
assert "yearly 02-29" in bot.replied[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCalendarPersistence:
|
|
def _extract_rid(self, reply: str) -> str:
|
|
return reply.split("#")[1].split(" ")[0]
|
|
|
|
def test_save_and_load_roundtrip(self):
|
|
bot = _FakeBot()
|
|
data = {
|
|
"type": "at",
|
|
"target": "#test",
|
|
"nick": "alice",
|
|
"label": "test label",
|
|
"fire_iso": "2099-06-15T12:00:00+00:00",
|
|
"month_day": None,
|
|
"time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "abc123", data)
|
|
raw = bot.state.get("remind", "abc123")
|
|
assert raw is not None
|
|
loaded = json.loads(raw)
|
|
assert loaded == data
|
|
|
|
def test_delete_removes(self):
|
|
bot = _FakeBot()
|
|
_save(bot, "abc123", {"type": "at"})
|
|
_delete_saved(bot, "abc123")
|
|
assert bot.state.get("remind", "abc123") is None
|
|
|
|
def test_cancel_deletes_from_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
|
|
async def inner():
|
|
await _run_cmd(bot, _msg(f"!remind at {future} cancel me"))
|
|
rid = self._extract_rid(bot.replied[0])
|
|
assert bot.state.get("remind", rid) is not None
|
|
bot.replied.clear()
|
|
await cmd_remind(bot, _msg(f"!remind cancel {rid}"))
|
|
await asyncio.sleep(0)
|
|
assert bot.state.get("remind", rid) is None
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_at_fire_deletes_from_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
|
|
async def inner():
|
|
rid = "fire01"
|
|
fire_dt = datetime.now(timezone.utc) + timedelta(seconds=0)
|
|
data = {
|
|
"type": "at", "target": "#ch", "nick": "alice",
|
|
"label": "fire now", "fire_iso": fire_dt.isoformat(),
|
|
"month_day": None, "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, rid, data)
|
|
_calendar.add(rid)
|
|
task = asyncio.create_task(
|
|
_schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"),
|
|
)
|
|
_reminders[rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False)
|
|
_by_user[("#ch", "alice")] = [rid]
|
|
await task
|
|
assert bot.state.get("remind", rid) is None
|
|
|
|
asyncio.run(inner())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Restore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRestore:
|
|
def test_restores_at_from_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
data = {
|
|
"type": "at", "target": "#ch", "nick": "alice",
|
|
"label": "future task", "fire_iso": fire_dt.isoformat(),
|
|
"month_day": None, "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "rest01", data)
|
|
|
|
async def inner():
|
|
_restore(bot)
|
|
assert "rest01" in _reminders
|
|
assert "rest01" in _calendar
|
|
entry = _reminders["rest01"]
|
|
assert not entry[0].done()
|
|
entry[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_restores_yearly_from_state(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
fire_dt = datetime.now(timezone.utc) + timedelta(days=180)
|
|
data = {
|
|
"type": "yearly", "target": "#ch", "nick": "bob",
|
|
"label": "anniversary", "fire_iso": fire_dt.isoformat(),
|
|
"month_day": "06-15", "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "rest02", data)
|
|
|
|
async def inner():
|
|
_restore(bot)
|
|
assert "rest02" in _reminders
|
|
assert "rest02" in _calendar
|
|
entry = _reminders["rest02"]
|
|
assert not entry[0].done()
|
|
entry[0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_skips_active_rids(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
data = {
|
|
"type": "at", "target": "#ch", "nick": "alice",
|
|
"label": "active", "fire_iso": fire_dt.isoformat(),
|
|
"month_day": None, "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "skip01", data)
|
|
|
|
async def inner():
|
|
# Pre-populate with an active task
|
|
dummy = asyncio.create_task(asyncio.sleep(9999))
|
|
_reminders["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False)
|
|
_restore(bot)
|
|
# Should still be the dummy task, not replaced
|
|
assert _reminders["skip01"][0] is dummy
|
|
dummy.cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_past_at_cleaned_up(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
past_dt = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
data = {
|
|
"type": "at", "target": "#ch", "nick": "alice",
|
|
"label": "expired", "fire_iso": past_dt.isoformat(),
|
|
"month_day": None, "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "past01", data)
|
|
|
|
async def inner():
|
|
_restore(bot)
|
|
# Past at-reminder should be deleted from state, not scheduled
|
|
assert "past01" not in _reminders
|
|
assert bot.state.get("remind", "past01") is None
|
|
|
|
asyncio.run(inner())
|
|
|
|
def test_past_yearly_recalculated(self):
|
|
_clear()
|
|
bot = _FakeBot()
|
|
past_dt = datetime.now(timezone.utc) - timedelta(days=30)
|
|
data = {
|
|
"type": "yearly", "target": "#ch", "nick": "alice",
|
|
"label": "birthday", "fire_iso": past_dt.isoformat(),
|
|
"month_day": "01-01", "time_str": "12:00",
|
|
"created": "12:00:00 UTC",
|
|
}
|
|
_save(bot, "yearly01", data)
|
|
|
|
async def inner():
|
|
_restore(bot)
|
|
assert "yearly01" in _reminders
|
|
# fire_iso should have been updated to a future date
|
|
raw = bot.state.get("remind", "yearly01")
|
|
updated = json.loads(raw)
|
|
new_fire = datetime.fromisoformat(updated["fire_iso"])
|
|
assert new_fire > datetime.now(timezone.utc)
|
|
_reminders["yearly01"][0].cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
asyncio.run(inner())
|