diff --git a/bot/exts/fun/yodaify.py b/bot/exts/fun/yodaify.py new file mode 100644 index 0000000000..803a17a995 --- /dev/null +++ b/bot/exts/fun/yodaify.py @@ -0,0 +1,89 @@ +import re +from discord.ext import commands +from discord import AllowedMentions + +from bot.bot import Bot + + +class Yodaify(commands.Cog): + """Cog for the yodaify command.""" + + def _yodaify_sentence(self, sentence: str) -> str: + """Convert a single sentence to Yoda speech pattern.""" + sentence = sentence.strip().rstrip('.') + + # Basic pattern matching for subject-verb-object + # Looking for patterns like "I am driving a car" -> "Driving a car, I am" + words = sentence.split() + if len(words) < 3: + return sentence + "." + + # Common subject-verb patterns to identify the split point + subject_verb_patterns = [ + (r'^(i|you|he|she|it|we|they)\s+(am|are|is|was|were)\s+', 2), + (r'^(i|you|he|she|it|we|they)\s+\w+\s+', 2), + ] + + for pattern, split_index in subject_verb_patterns: + if re.match(pattern, sentence.lower()): + subject = ' '.join(words[:split_index]) + predicate = ' '.join(words[split_index:]) + if predicate: + return f"{predicate.lower()}, {subject.lower()}." + + # If no pattern matches, return original with message + return None + + @commands.command(name="yoda") + async def yodaify(self, ctx: commands.Context, *, text: str | None) -> None: + """ + Convert the provided text into Yoda-like speech. + + The command transforms sentences from subject-verb-object format + to object-subject-verb format, similar to how Yoda speaks. + """ + if not text: + return # Help message handled by Discord.py's help system + + # Split into sentences (considering multiple punctuation types) + sentences = re.split(r'[.!?]+\s*', text.strip()) + sentences = [s for s in sentences if s] + + yoda_sentences = [] + any_converted = False + + for sentence in sentences: + yoda_sentence = self._yodaify_sentence(sentence) + if yoda_sentence is None: + yoda_sentences.append(sentence + ".") + else: + any_converted = True + yoda_sentences.append(yoda_sentence) + + if not any_converted: + await ctx.send( + f"Yodafication this doesn't need, {ctx.author.display_name}!\n>>> {text}", + allowed_mentions=AllowedMentions.none() + ) + return + + for i in range(len(yoda_sentences)): + sentence = yoda_sentences[i] + words = sentence.split() + for j in range(len(words)): + if words[j].lower() == "i": + words[j] = "I" + sentence = ' '.join(words) + sentence = sentence[0].upper() + sentence[1:] + yoda_sentences[i] = sentence + result = ' '.join(yoda_sentences) + + await ctx.send( + f">>> {result}", + allowed_mentions=AllowedMentions.none() + ) + + +async def setup(bot: Bot) -> None: + """Loads the yodaify cog.""" + await bot.add_cog(Yodaify()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/__init__.py b/tests/exts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/fun/__init__.py b/tests/exts/fun/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/fun/test_yoda.py b/tests/exts/fun/test_yoda.py new file mode 100644 index 0000000000..41a06d3ee0 --- /dev/null +++ b/tests/exts/fun/test_yoda.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from bot.exts.fun.yodaify import Yodaify + + +@pytest.mark.asyncio +@patch("bot.exts.fun.yodaify.Yodaify.yodaify", new_callable=AsyncMock) +async def test_yodaify_command_is_called(mock_yodaify): + """ + Requirement 1 Bot-command: When a user writes .yoda it runs the function. + """ + ctx = AsyncMock() + + # Simulate a user writing ".yodaify I am driving a car." + await mock_yodaify(ctx, text="I am driving a car.") + + # Verify that the command was indeed called with the expected arguments + mock_yodaify.assert_awaited_once_with(ctx, text="I am driving a car.") + + +async def yodaify_conversion_helper(text, converted_text): + """ + Requirement 5 Format: The returned text should have the format object-subject-verb. + Requirement 7 Consistency: No words should be lost during the conversion. + Requirement 8 Capitalization: The sentence should be capitalized correctly. + """ + + cog = Yodaify() + + mock_ctx = MagicMock() + mock_ctx.author.display_name = "TestUser" + mock_ctx.send = AsyncMock() + mock_ctx.author.edit = AsyncMock() + + await cog.yodaify.callback(cog, mock_ctx, text=text) + + # Ensure a message was sent + mock_ctx.send.assert_called_once() + args, kwargs = mock_ctx.send.call_args + sent_message = args[0] + assert sent_message == converted_text, f"Unexpected sent message: {sent_message}" + + +@pytest.mark.asyncio +async def test_yodaify_conversion_1(): + await yodaify_conversion_helper("I like trains.", ">>> " + "Trains, I like.") + + +@pytest.mark.asyncio +async def test_yodaify_conversion_2(): + await yodaify_conversion_helper("I am driving a car.", ">>> " + "Driving a car, I am.") + + +@pytest.mark.asyncio +async def test_yodaify_conversion_3(): + await yodaify_conversion_helper("She likes my new van.", ">>> " + "My new van, she likes.") + + +@pytest.mark.asyncio +async def test_yodaify_conversion_4(): + await yodaify_conversion_helper("We should get out of here.", ">>> " + "Get out of here, we should.") + + +@pytest.mark.asyncio +async def test_yodaify_invalid_sentecne(): + """ + Requirement 6 Invalid sentence: If no changes to the format can be made, it should return: “Yodafication this doesn't need {username}!” + the original text. + """ + await yodaify_conversion_helper("sghafuj fhaslkhglf ajshflka.", "Yodafication this doesn't need, TestUser!" + "\n>>> " + "sghafuj fhaslkhglf ajshflka.") + + +@pytest.mark.asyncio +async def test_yodaify_multiple_sentances(): + """ + Requirement 9 Multiple sentences: If there are multiple sentences in the input, they should be converted separately. + """ + await yodaify_conversion_helper("I like trains. I am driving a car. She likes my new van.", ">>> " + "Trains, I like. Driving a car, I am. My new van, she likes.") +