diff --git a/bot.py b/bot.py index 9258048..8ce2d40 100644 --- a/bot.py +++ b/bot.py @@ -45,8 +45,7 @@ async def setup_hook(self): self.add_view(QueueView()) self.add_view(TAView()) daily_reset.start() - auto_queue_scheduler.start(self) - + auto_queue_scheduler.start(self) await self.tree.sync() async def on_ready(self): @@ -57,7 +56,7 @@ async def on_ready(self): if len(self.queue.entries) > 0: self._player_task = asyncio.create_task(self._play_notifications()) - async def get_ta_voice_channel(self) -> discord.VoiceChannel | None: + async def _get_ta_voice_channel(self) -> discord.VoiceChannel | None: for guild in self.guilds: return get(guild.voice_channels, name=TA_VOICE_CHANNEL_NAME) @@ -75,7 +74,7 @@ async def _play_notifications(self) -> None: if empty: break - channel = await self.get_ta_voice_channel() + channel = await self._get_ta_voice_channel() if channel is None: await asyncio.sleep(60) continue @@ -122,23 +121,23 @@ async def _play_notifications(self) -> None: self._player_task = None - async def get_ta_channel(self) -> discord.TextChannel | None: + async def _get_ta_channel(self) -> discord.TextChannel | None: for guild in self.guilds: return get(guild.text_channels, name=TA_TEXT_CHANNEL_NAME) return None - async def get_help_channel(self) -> discord.TextChannel | None: + async def _get_help_channel(self) -> discord.TextChannel | None: for guild in self.guilds: return get(guild.text_channels, name=HELP_CHANNEL_NAME) return None - async def build_queue_status(self) -> str: + async def _build_queue_status(self) -> str: status = "OPEN" if self.queue.is_open else "CLOSED" queue_text = await self.queue.view() return f"**Help Queue Status: {status}**\n{queue_text}" async def _get_status_message(self) -> discord.Message | None: - ta_channel = await self.get_ta_channel() + ta_channel = await self._get_ta_channel() if ta_channel is None: return None @@ -153,25 +152,25 @@ async def _get_status_message(self) -> discord.Message | None: self.queue_status_message_id = message.id return message - status_message = await ta_channel.send(await self.build_queue_status()) + status_message = await ta_channel.send(await self._build_queue_status()) self.queue_status_message_id = status_message.id return status_message - async def _update_status(self) -> None: + async def update_status_for_students(self) -> None: status_message = await self._get_status_message() if status_message is None: return - await status_message.edit(content=await self.build_queue_status()) + await status_message.edit(content=await self._build_queue_status()) - async def build_help_queue_count(self) -> str: + async def _build_help_queue_count(self) -> str: status = "OPEN" if self.queue.is_open else "CLOSED" async with self.queue.lock: count = len(self.queue.entries) return f"**Help Queue Status: {status} — {count} student{'s' if count != 1 else ''} in queue**" async def _get_count_message(self) -> discord.Message | None: - help_channel = await self.get_help_channel() + help_channel = await self._get_help_channel() if help_channel is None: return None @@ -186,16 +185,16 @@ async def _get_count_message(self) -> discord.Message | None: self.help_queue_count_message_id = message.id return message - count_message = await help_channel.send(await self.build_help_queue_count()) + count_message = await help_channel.send(await self._build_help_queue_count()) self.help_queue_count_message_id = count_message.id return count_message - async def _update_count(self) -> None: + async def update_status_for_tas(self) -> None: count_message = await self._get_count_message() if count_message is None: return - await count_message.edit(content=await self.build_help_queue_count()) + await count_message.edit(content=await self._build_help_queue_count()) async def queue_handler(self, interaction: discord.Interaction, question, is_passoff, in_person, student_name: str): """ diff --git a/tests/test_remove_student.py b/tests/test_remove_student.py new file mode 100644 index 0000000..73c8dd8 --- /dev/null +++ b/tests/test_remove_student.py @@ -0,0 +1,392 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock +from datetime import datetime + +import discord + +from records import QueueEntry +from help_queue import HelpQueue + +"""This file conducts tests on the funcitonality of the Remove button using Mocks to simulate interactions with Discord's APi.""" +# ========================================================== +# Helper functions +# ========================================================== + +def make_entry(user_id: int, username: str = "", student_name: str = "", + details: str = "", is_passoff: bool = False, in_person: bool = False): + return QueueEntry( + user_id=user_id, + username=username or f"user_{user_id}", + student_name=student_name, + details=details, + is_passoff=is_passoff, + timestamp=datetime.now(), + in_person=in_person, + ) + + +def make_mock_interaction(queue: HelpQueue, user_id: int = 999, + display_name: str = "TA_Test"): + """Construct a mock Interaction with a client.queue.""" + mock = MagicMock(spec=discord.Interaction) + mock.client = MagicMock() + mock.client.queue = queue + mock.user = MagicMock() + mock.user.id = user_id + mock.user.display_name = display_name + mock.response = MagicMock() + mock.response.send_message = AsyncMock() + mock.response.send_modal = AsyncMock() + mock.response.defer = AsyncMock() + mock.followup = MagicMock() + mock.followup.send = AsyncMock() + return mock + + + +# ========================================================== +# HelpQueue Modification tests (Most important) +# ========================================================== + +class TestHelpQueue(unittest.IsolatedAsyncioTestCase): + + async def test_remove_existing_user(self): + q = HelpQueue() + await q.add(make_entry(1)) + await q.add(make_entry(2)) + await q.remove(1) + self.assertEqual(len(q.entries), 1) + self.assertEqual(q.entries[0].user_id, 2) + + async def test_remove_nonexistent_user_does_nothing(self): + q = HelpQueue() + await q.add(make_entry(1)) + await q.remove(999) + self.assertEqual(len(q.entries), 1) + + async def test_remove_from_empty_queue(self): + q = HelpQueue() + await q.remove(1) + self.assertEqual(len(q.entries), 0) + + async def test_get_front_after_remove(self): + q = HelpQueue() + await q.add(make_entry(1)) + await q.add(make_entry(2)) + self.assertEqual((await q.get_front()).user_id, 1) + await q.remove(1) + self.assertEqual((await q.get_front()).user_id, 2) + + async def test_get_front_empty_queue(self): + q = HelpQueue() + self.assertIsNone(await q.get_front()) + + +# ========================================================== +# RemoveStudentView construction +# ========================================================== + +class TestRemoveStudentViewConstruction(unittest.IsolatedAsyncioTestCase): + + def test_options_include_cancel_first(self): + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(1, username="alice")]) + select = view.children[0] + self.assertEqual(select.options[0].value, "__cancel__") + self.assertEqual(select.options[0].label, "— Cancel —") + + def test_emoji_passoff_vs_question(self): + from ui.views.ta_view import RemoveStudentView + entries = [ + make_entry(1, username="a", is_passoff=True), + make_entry(2, username="b", is_passoff=False), + ] + view = RemoveStudentView(entries) + select = view.children[0] + self.assertEqual(select.options[1].emoji.name, "✅") + self.assertEqual(select.options[2].emoji.name, "❓") + + def test_label_prefers_student_name(self): + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(1, username="discord_abc", student_name="张三")]) + self.assertEqual(view.children[0].options[1].label, "张三") + + def test_label_falls_back_to_username(self): + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(1, username="discord_abc", student_name="")]) + self.assertEqual(view.children[0].options[1].label, "discord_abc") + + def test_label_truncated_at_100_chars(self): + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(1, username="x", student_name="A" * 150)]) + opt = view.children[0].options[1] + self.assertLessEqual(len(opt.label), 100) + self.assertTrue(opt.label.endswith("...")) + + def test_description_truncated(self): + """description is at most 100 characters long (including the "#N " prefix).""" + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(1, username="x", details="D" * 200)]) + opt = view.children[0].options[1] + self.assertLessEqual(len(opt.description), 100) + + def test_value_is_user_id_string(self): + from ui.views.ta_view import RemoveStudentView + view = RemoveStudentView([make_entry(123456, username="alice")]) + self.assertEqual(view.children[0].options[1].value, "123456") + + +# ========================================================== +# RemoveStudentView Callback +# ========================================================== + +class TestRemoveStudentViewCallback(unittest.IsolatedAsyncioTestCase): + + async def test_cancel_option_defers_and_returns(self): + """Select Cancel → defer and do not show the Modal.""" + from ui.views.ta_view import RemoveStudentView + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + + interaction = make_mock_interaction(q) + view = RemoveStudentView(q.entries) + + with patch.object(discord.ui.Select, 'values', + new_callable=PropertyMock) as mock_values: + mock_values.return_value = ["__cancel__"] + await view.select_callback(interaction) + + interaction.response.defer.assert_awaited_once() + interaction.response.send_modal.assert_not_awaited() + + async def test_select_student_sends_modal(self): + """Select a student → show RemoveConfirmModal with correct parameters.""" + from ui.views.ta_view import RemoveStudentView + q = HelpQueue() + await q.add(make_entry(1, username="alice", student_name="Alice")) + + interaction = make_mock_interaction(q) + view = RemoveStudentView(q.entries) + + with patch.object(discord.ui.Select, 'values', + new_callable=PropertyMock) as mock_values: + mock_values.return_value = ["1"] + await view.select_callback(interaction) + + interaction.response.send_modal.assert_awaited_once() + modal = interaction.response.send_modal.call_args[0][0] + self.assertEqual(modal.student_user_id, 1) + self.assertEqual(modal.student_name, "Alice") + + async def test_student_already_removed_by_another_ta(self): + """Concurrency: the selected student has already been removed by another TA.""" + from ui.views.ta_view import RemoveStudentView + q = HelpQueue() + # entry is not in the queue (simulate already removed) + entry = make_entry(2, username="bob") + + interaction = make_mock_interaction(q) + view = RemoveStudentView([entry]) + + with patch.object(discord.ui.Select, 'values', + new_callable=PropertyMock) as mock_values: + mock_values.return_value = ["2"] + await view.select_callback(interaction) + + interaction.response.send_message.assert_awaited_once() + msg = interaction.response.send_message.call_args[0][0] + self.assertIn("no longer in the queue", msg) + + +# ========================================================== +# RemoveConfirmModal functionality +# ========================================================== + +class TestRemoveConfirmModal(unittest.IsolatedAsyncioTestCase): + + def test_init_stores_user_id_and_display_name(self): + from ui.modals import RemoveConfirmModal + modal = RemoveConfirmModal(123, "Alice") + self.assertEqual(modal.student_user_id, 123) + self.assertEqual(modal.student_name, "Alice") + + async def test_on_submit_removes_student(self): + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + await q.add(make_entry(2, username="bob")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 1 + mock_user.display_name = "alice" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()): + modal = RemoveConfirmModal(1, "Alice") + await modal.on_submit(interaction) + + self.assertEqual(len(q.entries), 1) + self.assertEqual(q.entries[0].user_id, 2) + + async def test_on_submit_notifies_new_front(self): + """Removing the front of the queue calls notify_next_if_changed to notify the new front.""" + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + await q.add(make_entry(2, username="bob")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 1 + mock_user.display_name = "alice" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: + modal = RemoveConfirmModal(1, "Alice") + await modal.on_submit(interaction) + + mock_notify.assert_awaited_once() + # The second argument front_before is the previous front of the queue (alice, user_id=1). + self.assertEqual(mock_notify.call_args[0][1].user_id, 1) + + async def test_on_submit_no_notify_when_removed_not_front(self): + """Removing a non-front student still calls notify_next_if_changed, but the old front remains unchanged.""" + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + await q.add(make_entry(2, username="bob")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 2 + mock_user.display_name = "bob" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: + modal = RemoveConfirmModal(2, "Bob") + await modal.on_submit(interaction) + + mock_notify.assert_awaited_once() + # The old front is alice (user_id=1), which differs from the removed bob (user_id=2). + # notify_next_if_changed will compare the front before and after and should not send a DM if it is still alice. + self.assertEqual(mock_notify.call_args[0][1].user_id, 1) + + async def test_on_submit_dm_to_student(self): + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 1 + mock_user.display_name = "alice" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()): + modal = RemoveConfirmModal(1, "Alice") + await modal.on_submit(interaction) + + mock_user.send.assert_awaited_once() + self.assertIn("removed from the CS240 help queue", mock_user.send.call_args[0][0]) + + async def test_on_submit_dm_includes_reason(self): + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 1 + mock_user.display_name = "alice" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()): + modal = RemoveConfirmModal(1, "Alice") + modal.reason = MagicMock() + modal.reason.value = "Asked too many questions" + await modal.on_submit(interaction) + + dm_text = mock_user.send.call_args[0][0] + self.assertIn("Reason:", dm_text) + self.assertIn("Asked too many questions", dm_text) + + async def test_on_submit_success_message(self): + from ui.modals import RemoveConfirmModal + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + + interaction = make_mock_interaction(q) + mock_user = MagicMock() + mock_user.id = 1 + mock_user.display_name = "alice" + mock_user.send = AsyncMock() + interaction.client.fetch_user = AsyncMock(return_value=mock_user) + interaction.response.send_message = AsyncMock() + + with patch("ui.modals.update_queue_messages", AsyncMock()), \ + patch("ui.modals.notify_next_if_changed", AsyncMock()): + modal = RemoveConfirmModal(1, "Alice") + await modal.on_submit(interaction) + + interaction.response.send_message.assert_awaited_once() + msg = interaction.response.send_message.call_args[0][0] + self.assertIn("has been removed from the queue by", msg) + + +# ========================================================== +# TAView.remove_from_queue button +# ========================================================== + +class TestTAViewRemoveButton(unittest.IsolatedAsyncioTestCase): + + async def test_empty_queue_shows_message(self): + from ui.views.ta_view import TAView + q = HelpQueue() + interaction = make_mock_interaction(q) + view = TAView() + + for item in view.children: + if item.custom_id == "remove_from_queue": + await item.callback(interaction) + break + + interaction.response.send_message.assert_awaited_once() + msg = interaction.response.send_message.call_args[0][0] + self.assertEqual(msg, "Queue is empty.") + + async def test_nonempty_queue_shows_select_with_legend(self): + from ui.views.ta_view import TAView, RemoveStudentView + q = HelpQueue() + await q.add(make_entry(1, username="alice")) + + interaction = make_mock_interaction(q) + view = TAView() + + for item in view.children: + if item.custom_id == "remove_from_queue": + await item.callback(interaction) + break + + interaction.response.send_message.assert_awaited_once() + sent_view = interaction.response.send_message.call_args[1]["view"] + self.assertIsInstance(sent_view, RemoveStudentView) + text = interaction.response.send_message.call_args[0][0] + self.assertIn("✅", text) + self.assertIn("❓", text) + self.assertIn("Passoff", text) + self.assertIn("Question", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/helpers/discord_helpers.py b/ui/helpers/discord_helpers.py index b41503c..4c89e04 100644 --- a/ui/helpers/discord_helpers.py +++ b/ui/helpers/discord_helpers.py @@ -30,10 +30,10 @@ async def notify_next_if_changed(client: discord.Client, before: Optional[QueueE await safe_dm_user(client, after.user_id, NEXT_IN_LINE_MSG) async def update_queue_messages(client: discord.Client) -> None: - if hasattr(client, "update_queue_status_message"): - await client.update_queue_status_message() - if hasattr(client, "update_help_queue_count_message"): - await client.update_help_queue_count_message() + if hasattr(client, "update_status_for_students"): + await client.update_status_for_students() + if hasattr(client, "update_status_for_tas"): + await client.update_status_for_tas() async def move_to_breakout(interaction: discord.Interaction, entry: QueueEntry): student: discord.Member = interaction.guild.get_member(entry.user_id) diff --git a/ui/modals.py b/ui/modals.py index 4839409..bf92943 100644 --- a/ui/modals.py +++ b/ui/modals.py @@ -1,9 +1,8 @@ import discord from datetime import datetime from db import get_times_helped_today, record_bot_issue -from ui.helpers.discord_helpers import get_channel, get_role, update_queue_messages +from ui.helpers.discord_helpers import get_channel, get_role, update_queue_messages, notify_next_if_changed from ui.helpers.constants import SHORT_TIMEOUT, TA_TEXT_CHANNEL_NAME - class HelpModal(discord.ui.Modal, title="Request Help"): name = discord.ui.TextInput( @@ -160,22 +159,36 @@ async def on_submit(self, interaction: discord.Interaction): class RemoveConfirmModal(discord.ui.Modal, title="Removal Confirmation"): - warning = discord.ui.TextDisplay("Are you sure? This will remove the student from the queue.") - input: discord.ui.TextInput = discord.ui.TextInput(label="Please input student username.", placeholder="bsharplydian", min_length=1) - reason: discord.ui.TextInput = discord.ui.TextInput(label="Provide a reason (optional)", placeholder="You've used the queue too many times today", required=False) + reason: discord.ui.TextInput = discord.ui.TextInput( + label="Provide a reason (optional)", + placeholder="You've used the queue too many times today", + required=False, + max_length=200 + ) - async def on_submit(self, interaction: discord.Interaction): - for entry in interaction.client.queue.entries: - if entry.username == self.input.value: - user: discord.User = await interaction.client.fetch_user(entry.user_id) - await interaction.client.queue.remove(user.id) - await update_queue_messages(interaction.client) - await user.send(f"You have been removed from the CS240 help queue. {"Reason: " if self.reason.value != "" else ""}{self.reason.value}") - await interaction.response.send_message(f"{user.display_name} has been removed from the queue by {interaction.user.display_name}.", delete_after=60*5) - return - - await interaction.response.send_message(f"No student with the username \"{self.input.value}\" in the queue.", ephemeral=True, delete_after=10) + def __init__(self, student_user_id: int, student_name: str): + super().__init__() + self.student_user_id = student_user_id + self.student_name = student_name + self.add_item(discord.ui.TextDisplay( + f"This will remove {student_name} from the queue." + )) + async def on_submit(self, interaction: discord.Interaction): + front_before = await interaction.client.queue.get_front() + user: discord.User = await interaction.client.fetch_user(self.student_user_id) + await interaction.client.queue.remove(self.student_user_id) + await update_queue_messages(interaction.client) + + await notify_next_if_changed(interaction.client, front_before) + reason_suffix = f" Reason: {self.reason.value}" if self.reason.value else "" + await user.send( + f"You have been removed from the CS240 help queue.{reason_suffix}" + ) + await interaction.response.send_message( + f"{user.display_name} has been removed from the queue by {interaction.user.display_name}.", + delete_after=60 * 5 + ) class EditQueueHoursModal(discord.ui.Modal, title="Edit Queue Hours"): open_hour = discord.ui.TextInput( diff --git a/ui/views/ta_view.py b/ui/views/ta_view.py index cd1bcf3..e878e95 100644 --- a/ui/views/ta_view.py +++ b/ui/views/ta_view.py @@ -8,6 +8,67 @@ from ui.helpers.utils import fixed_width from ui.helpers.discord_helpers import get_channel, get_role, move_to_breakout, safe_dm_user, notify_next_if_changed, update_queue_messages +class RemoveStudentView(discord.ui.View): + def __init__(self, entries): + super().__init__(timeout=30) + + options = [ + discord.SelectOption( + label="— Cancel —", + value="__cancel__", + description="Reset selection without removing anyone", + emoji="↩️", + ) + ] + for i, entry in enumerate(entries, start=1): + label = entry.student_name if entry.student_name else entry.username + if len(label) > 100: + label = label[:97] + "..." + desc = entry.details if entry.details else "" + if len(desc) > 100: + desc = desc[:93] + "..." # 96 chars + "#N " prefix ≤ 100 + + emoji = "✅" if entry.is_passoff else "❓" + + options.append( + discord.SelectOption( + label=label, + value=str(entry.user_id), + description=f"#{i} {desc}" if desc else f"#{i} in queue", + emoji=emoji, + ) + ) + + select = discord.ui.Select( + placeholder="Select a student to remove...", + options=options, + min_values=1, + max_values=1, + ) + select.callback = self.select_callback + self.add_item(select) + + async def select_callback(self, interaction: discord.Interaction): + selected = self.children[0].values[0] + if selected == "__cancel__": + await interaction.response.defer() + return + user_id = int(self.children[0].values[0]) + + entry = next( + (e for e in interaction.client.queue.entries if e.user_id == user_id), + None + ) + if not entry: + await interaction.response.send_message( + "That student is no longer in the queue.", + ephemeral=True, delete_after=10 + ) + return + + student_name = entry.student_name if entry.student_name else entry.username + await interaction.response.send_modal(RemoveConfirmModal(user_id, student_name)) + class TAQueueControls1(discord.ui.ActionRow[discord.ui.LayoutView]): view: "TAView" @discord.ui.button(label="Next Student", style=discord.ButtonStyle.blurple, custom_id="next", emoji="➡️") @@ -158,7 +219,17 @@ async def clear_queue(self, interaction: discord.Interaction, button): @discord.ui.button(label="Remove Student", style=discord.ButtonStyle.danger, custom_id="remove_from_queue", emoji="🗑️") async def remove_from_queue(self, interaction: discord.Interaction, button): - await interaction.response.send_modal(RemoveConfirmModal()) + entries = interaction.client.queue.entries + if not entries: + await interaction.response.send_message( + "Queue is empty.", ephemeral=True, delete_after=10 + ) + return + view = RemoveStudentView(entries) + await interaction.response.send_message( + "Select a student to remove:\n✅ = Passoff | ❓ = Question", + view=view, ephemeral=True, delete_after=60 + ) class TAQueueInformation(discord.ui.ActionRow[discord.ui.LayoutView]): view: "TAView"