"""Unit coverage for SPEC-006 operator controls (OPS-01..09).""" import time from unittest.mock import AsyncMock, MagicMock from discord import Message, TextChannel, User from fjerkroa_bot.ai_responder import AIMessage, AIResponse from .test_main import TestBotBase class OpsBase(TestBotBase): def staff_msg(self, content: str) -> Message: message = MagicMock(spec=Message) message.content = content message.author = MagicMock(spec=User) message.author.bot = False message.author.id = 999 message.channel = self.bot.staff_channel return message def public_msg(self, content: str) -> Message: message = self.create_message(content) message.content = content return message class TestStaffCommandAuth(OpsBase): async def test_commands_ignored_outside_staff_channel(self): """OPS-01: !bot commands in a public channel do not flip flags.""" self.bot.handle_message_through_responder = AsyncMock() await self.bot.on_message(self.public_msg("!bot pause")) self.assertTrue(self.bot.replies_enabled) self.bot.handle_message_through_responder.assert_awaited_once() async def test_commands_honored_in_staff_channel(self): """OPS-01: !bot commands in the staff channel are executed.""" await self.bot.on_message(self.staff_msg("!bot pause")) self.assertFalse(self.bot.replies_enabled) class TestPauseResume(OpsBase): async def test_pause_blocks_public_replies(self): """OPS-02: paused bot ignores public messages; resume restores replying.""" self.bot.handle_message_through_responder = AsyncMock() await self.bot.on_message(self.staff_msg("!bot pause")) await self.bot.on_message(self.public_msg("hello?")) self.bot.handle_message_through_responder.assert_not_awaited() await self.bot.on_message(self.staff_msg("!bot resume")) self.assertTrue(self.bot.replies_enabled) await self.bot.on_message(self.public_msg("hello again")) self.bot.handle_message_through_responder.assert_awaited_once() class TestImageKillSwitch(OpsBase): async def test_images_off_strips_picture(self): """OPS-03: with images off the picture request is dropped, answer still sent.""" await self.bot.on_message(self.staff_msg("!bot images off")) self.assertFalse(self.bot.images_enabled) response = AIResponse("here is your cat", True, "chat", None, "a cat", False, False) self.bot.send_message_with_typing = AsyncMock(return_value=response) self.bot.send_answer_with_typing = AsyncMock() origin = MagicMock(spec=TextChannel) await self.bot.respond(AIMessage("alice", "draw a cat", "chat"), origin) sent = self.bot.send_answer_with_typing.await_args.args[0] self.assertIsNone(sent.picture) self.assertEqual(sent.answer, "here is your cat") class TestQuietMode(OpsBase): async def test_quiet_pauses_then_auto_resumes(self): """OPS-04: !bot quiet N silences replies for N minutes, then auto-resumes.""" await self.bot.on_message(self.staff_msg("!bot quiet 10")) self.assertFalse(self.bot.replies_allowed()) self.bot.quiet_until = time.monotonic() - 1 self.assertTrue(self.bot.replies_allowed()) class TestStatus(OpsBase): async def test_status_reports_flags(self): """OPS-05: !bot status answers in the staff channel with the flag state.""" await self.bot.on_message(self.staff_msg("!bot status")) self.bot.staff_channel.send.assert_awaited() text = self.bot.staff_channel.send.await_args.args[0] self.assertIn("replies", text) self.assertIn("images", text) self.assertIn("tasks", text) class TestKeywordAlerts(OpsBase): async def test_keyword_forces_staff_alert(self): """OPS-06: staff-alert-keywords match forces an alert when the model set none.""" self.bot.config["staff-alert-keywords"] = ["(?i)hjelp|help"] response = AIResponse("ok", True, "chat", None, None, False, False) self.bot.send_message_with_typing = AsyncMock(return_value=response) self.bot.send_answer_with_typing = AsyncMock() await self.bot.respond(AIMessage("guest", "HELP at table 4", "chat"), MagicMock(spec=TextChannel)) self.bot.staff_channel.send.assert_awaited_once() self.assertIn("guest", self.bot.staff_channel.send.await_args.args[0]) class TestAlertRateLimit(OpsBase): async def test_alerts_rate_limited(self): """OPS-07: staff alerts above the hourly cap are logged, not sent.""" self.bot.config["staff-alert-max-per-hour"] = 2 await self.bot.send_staff_alert("one") await self.bot.send_staff_alert("two") await self.bot.send_staff_alert("three") self.assertEqual(self.bot.staff_channel.send.await_count, 2) class TestAlertFallback(OpsBase): async def test_lost_alert_is_logged_not_raised(self): """OPS-08: no staff channel -> alert goes to the error log, no crash.""" self.bot.staff_channel = None with self.assertLogs(level="ERROR") as logs: await self.bot.send_staff_alert("nobody hears this") self.assertTrue(any("nobody hears this" in line for line in logs.output)) class TestTasksKillSwitch(OpsBase): async def test_tasks_off_blocks_bot_initiated(self): """OPS-09: !bot tasks off disables bot-initiated posting; on restores.""" self.assertTrue(self.bot.bot_initiated_allowed()) await self.bot.on_message(self.staff_msg("!bot tasks off")) self.assertFalse(self.bot.tasks_enabled) self.assertFalse(self.bot.bot_initiated_allowed()) await self.bot.on_message(self.staff_msg("!bot tasks on")) self.assertTrue(self.bot.bot_initiated_allowed()) async def test_pause_also_blocks_bot_initiated(self): """OPS-09: bot-initiated posts respect pause/quiet.""" await self.bot.on_message(self.staff_msg("!bot pause")) self.assertFalse(self.bot.bot_initiated_allowed()) class TestHelp(OpsBase): STAFF_CMDS = ( "pause", "resume", "quiet ", "status", "spend", "images on|off", "memory ", "forget-fact ", "pin ", "unpin ", "pins", "task-approve ", "task-cancel ", "(list)", ) def test_staff_help_is_complete_and_grouped(self): """OPS-17: staff help lists every operator command, grouped by purpose.""" text = self.bot._help_text(staff=True) for cmd in self.STAFF_CMDS: self.assertIn(cmd, text, f"missing {cmd!r} in staff help") for group in ("Control:", "Cost:", "Memory:", "Tasks:"): self.assertIn(group, text) for cmd in ("!help", "!forgetme", "!privacy", "!wichtel"): self.assertIn(cmd, text) # everywhere-commands shown too def test_user_help_hides_operator_commands(self): """OPS-17: non-staff help shows only the everyone-commands.""" text = self.bot._help_text(staff=False) for cmd in ("!help", "!forgetme", "!privacy", "!wichtel"): self.assertIn(cmd, text) for op in ("task-approve", "images on|off", "spend", "Staff commands", "Control:"): self.assertNotIn(op, text) async def test_bot_help_in_staff_channel_returns_full_help(self): """OPS-17: `!bot help` answers with the complete staff help.""" await self.bot.on_message(self.staff_msg("!bot help")) text = self.bot.staff_channel.send.await_args.args[0] self.assertIn("Staff commands", text) self.assertIn("task-cancel ", text) async def test_unknown_bot_command_falls_back_to_help(self): """OPS-17: an unrecognised `!bot` command shows the full help, not a partial line.""" await self.bot.on_message(self.staff_msg("!bot wat")) text = self.bot.staff_channel.send.await_args.args[0] self.assertIn("Control:", text) async def test_help_in_public_channel_is_user_scoped(self): """OPS-17: `!help` in a normal channel lists only everyone-commands.""" msg = self.public_msg("!help") await self.bot.on_message(msg) text = msg.channel.send.await_args.args[0] self.assertIn("!forgetme", text) self.assertNotIn("Staff commands", text) self.assertNotIn("task-approve", text) async def test_help_works_while_paused(self): """OPS-17: help answers even when replies are paused.""" await self.bot.on_message(self.staff_msg("!bot pause")) msg = self.public_msg("!help") await self.bot.on_message(msg) msg.channel.send.assert_awaited()