diff --git a/plugins/remind.py b/plugins/remind.py index 7178fb5..87bbf7f 100644 --- a/plugins/remind.py +++ b/plugins/remind.py @@ -156,7 +156,9 @@ async def cmd_remind(bot, message): rest = parts[2] if len(parts) > 2 else "" parts = ["", "", *rest.split(None, 1)] # re-split: [_, _, duration, text] - duration = _parse_duration(parts[1] if not repeating else parts[2]) + dur_idx = 2 if repeating else 1 + dur_spec = parts[dur_idx] if dur_idx < len(parts) else "" + duration = _parse_duration(dur_spec) if duration is None: await bot.reply(message, "Invalid duration (use: 5m, 1h30m, 2d, 90s)") return diff --git a/tests/test_remind.py b/tests/test_remind.py index 0836882..82f5df7 100644 --- a/tests/test_remind.py +++ b/tests/test_remind.py @@ -1,9 +1,12 @@ """Tests for the remind plugin.""" +import asyncio import importlib.util import sys from pathlib import Path +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", @@ -13,11 +16,81 @@ sys.modules[_spec.name] = _mod _spec.loader.exec_module(_mod) from plugins.remind import ( # noqa: E402 + _by_user, + _cleanup, _format_duration, _make_id, _parse_duration, + _remind_once, + _remind_repeat, + _reminders, + cmd_remind, ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _FakeBot: + """Minimal bot stand-in that captures sent/replied messages.""" + + def __init__(self): + self.sent: list[tuple[str, str]] = [] + self.replied: list[str] = [] + + 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() + + +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): @@ -72,6 +145,10 @@ class TestParseDurationEdgeCases: assert _parse_duration("0d0h0m1s") == 1 +# --------------------------------------------------------------------------- +# Pure helpers: _format_duration +# --------------------------------------------------------------------------- + class TestFormatDuration: def test_minutes_and_seconds(self): assert _format_duration(90) == "1m30s" @@ -92,6 +169,10 @@ class TestFormatDuration: assert _format_duration(45) == "45s" +# --------------------------------------------------------------------------- +# Pure helpers: _make_id +# --------------------------------------------------------------------------- + class TestMakeId: def test_returns_six_char_hex(self): rid = _make_id("user", "check oven") @@ -102,3 +183,421 @@ class TestMakeId: 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 + + +# --------------------------------------------------------------------------- +# _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())