"""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())