Files
discord_bot/tests/test_spec_ops.py

202 lines
8.6 KiB
Python

"""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 <minutes>",
"status",
"spend",
"images on|off",
"memory <user>",
"forget-fact <id>",
"pin <channel|global>",
"unpin <id>",
"pins",
"task-approve <id>",
"task-cancel <id>",
"(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 <id>", 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()