Files
meshcore-bot/tests/test_command_prefix.py
agessaman b1a8b2e5d5 feat(command-manager): implement command prefix normalization for improved command handling
- Added `normalize_command_content` method to `CommandManager` to streamline command prefix handling, ensuring consistent processing of incoming messages.
- Updated `format_keyword_response`, `check_keywords`, and `execute` methods to utilize the new normalization logic, reducing redundancy and improving clarity.
- Refactored related command classes to remove legacy prefix stripping logic, enhancing maintainability and ensuring all commands adhere to the new normalization approach.
- Enhanced tests to cover various scenarios for command prefix handling, ensuring robust functionality across different configurations.
2026-06-27 09:58:10 -07:00

335 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for command prefix functionality
Tests that all commands properly handle command prefixes when enabled
"""
from configparser import ConfigParser
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from modules.command_manager import CommandManager
from modules.commands.base_command import BaseCommand
from modules.commands.hello_command import HelloCommand
from modules.commands.ping_command import PingCommand
from modules.models import MeshMessage
class MockTestCommand(BaseCommand):
"""Mock command for testing prefix functionality"""
name = "test"
keywords = ['test', 't']
description = "Test command"
category = "test"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the test command (required by abstract base class)"""
return True
@pytest.fixture
def mock_bot():
"""Create a mock bot instance"""
bot = Mock()
bot.logger = Mock()
bot.logger.debug = Mock()
bot.logger.info = Mock()
bot.logger.warning = Mock()
bot.logger.error = Mock()
bot.config = ConfigParser()
bot.config.add_section('Bot')
bot.config.add_section('Channels')
bot.config.set('Channels', 'monitor_channels', 'general')
bot.config.set('Channels', 'respond_to_dms', 'true')
bot.meshcore = None
bot.translator = None
bot.rate_limiter = Mock()
bot.rate_limiter.can_send = Mock(return_value=True)
bot.bot_tx_rate_limiter = Mock()
bot.bot_tx_rate_limiter.wait_for_tx = Mock()
bot.tx_delay_ms = 0
bot.bot_root = Path("/tmp")
bot._local_root = None # CommandManager uses bot_root / local / commands
return bot
@pytest.fixture
def mock_message():
"""Create a mock message"""
return MeshMessage(
content="test",
sender_id="TestUser",
channel="general",
is_dm=False
)
def _make_manager(mock_bot, commands=None):
with patch('modules.command_manager.PluginLoader') as mock_loader_class:
mock_loader = Mock()
mock_loader.load_all_plugins = Mock(return_value=commands or {})
mock_loader_class.return_value = mock_loader
return CommandManager(mock_bot)
def _msg(content: str) -> MeshMessage:
return MeshMessage(
content=content,
sender_id="TestUser",
channel="general",
is_dm=False,
)
class ExecuteOnlyCommand(BaseCommand):
"""Command that handles its own response via execute() (no response format)."""
name = "path"
keywords = ['path', 'p']
description = "Path command for prefix regression tests"
category = "test"
async def execute(self, message: MeshMessage) -> bool:
return True
class TestCommandPrefix:
"""Test command prefix functionality"""
def test_no_prefix_allows_commands(self, mock_bot, mock_message):
"""Test that without prefix configured, commands work normally"""
mock_bot.config.set('Bot', 'command_prefix', '')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
assert manager.normalize_command_content(mock_message) is True
assert command.matches_keyword(mock_message) is True
prefixed = _msg("!test")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
def test_prefix_required_when_configured(self, mock_bot, mock_message):
"""Test that when prefix is configured, it's required"""
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
prefixed = _msg("!test")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
unprefixed = _msg("test")
assert manager.normalize_command_content(unprefixed) is False
def test_dot_prefix(self, mock_bot, mock_message):
"""Test dot prefix (e.g., .ping)"""
mock_bot.config.set('Bot', 'command_prefix', '.')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
prefixed = _msg(".test")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
unprefixed = _msg("test")
assert manager.normalize_command_content(unprefixed) is False
def test_single_char_prefix(self, mock_bot, mock_message):
"""Test single character prefix (e.g., bping)"""
mock_bot.config.set('Bot', 'command_prefix', 'b')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
prefixed = _msg("btest")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
unprefixed = _msg("test")
assert manager.normalize_command_content(unprefixed) is False
def test_multi_char_prefix(self, mock_bot, mock_message):
"""Test multi-character prefix (e.g., abcping)"""
mock_bot.config.set('Bot', 'command_prefix', 'abc')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
prefixed = _msg("abctest")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
assert manager.normalize_command_content(_msg("test")) is False
assert manager.normalize_command_content(_msg("abtest")) is False
def test_prefix_with_whitespace(self, mock_bot, mock_message):
"""Test that prefix works with whitespace after it"""
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
spaced = _msg("! test")
assert manager.normalize_command_content(spaced) is True
assert command.matches_keyword(spaced) is True
tight = _msg("!test")
assert manager.normalize_command_content(tight) is True
assert command.matches_keyword(tight) is True
def test_prefix_with_keyword_variations(self, mock_bot, mock_message):
"""Test prefix with different keyword variations"""
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
for content in ("!test", "!t", "!test arg1 arg2"):
msg = _msg(content)
assert manager.normalize_command_content(msg) is True
assert command.matches_keyword(msg) is True
def test_hello_command_with_prefix(self, mock_bot, mock_message):
"""Test hello command specifically with prefix"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.set('Bot', 'bot_name', 'TestBot')
mock_bot.config.add_section('Hello_Command')
mock_bot.config.set('Hello_Command', 'enabled', 'true')
manager = _make_manager(mock_bot)
command = HelloCommand(mock_bot)
prefixed = _msg("!hello")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
assert manager.normalize_command_content(_msg("hello")) is False
def test_ping_command_with_prefix(self, mock_bot, mock_message):
"""Test ping command with prefix"""
mock_bot.config.set('Bot', 'command_prefix', '.')
mock_bot.config.add_section('Ping_Command')
mock_bot.config.set('Ping_Command', 'enabled', 'true')
manager = _make_manager(mock_bot)
command = PingCommand(mock_bot)
prefixed = _msg(".ping")
assert manager.normalize_command_content(prefixed) is True
assert command.matches_keyword(prefixed) is True
assert manager.normalize_command_content(_msg("ping")) is False
def test_command_manager_with_prefix(self, mock_bot, mock_message):
"""Test CommandManager handles prefix correctly"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.add_section('Keywords')
mock_bot.config.set('Keywords', 'keywords', '')
mock_bot.config.add_section('Custom_Syntax')
mock_bot.config.set('Custom_Syntax', 'custom_syntax', '')
manager = _make_manager(mock_bot)
assert manager.check_keywords(_msg("test")) == []
assert isinstance(manager.check_keywords(_msg("!test")), list)
def test_prefix_with_mentions(self, mock_bot, mock_message):
"""Test that prefix works correctly with @[username] mentions"""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.set('Bot', 'bot_name', 'TestBot')
mock_bot.config.set('Bot', 'respond_to_mentions', 'also')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
mock_bot.meshcore = Mock()
mock_bot.meshcore.self_info = {'name': 'TestBot'}
bot_mention = _msg("! test")
assert manager.normalize_command_content(bot_mention) is True
assert command.matches_keyword(bot_mention) is True
other_mention = _msg("!@[OtherUser] test")
assert manager.normalize_command_content(other_mention) is True
assert command.matches_keyword(other_mention) is False
def test_different_prefixes_dont_match(self, mock_bot, mock_message):
"""Test that wrong prefix doesn't match"""
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
for content in (".test", "btest", "abctest"):
assert manager.normalize_command_content(_msg(content)) is False
def test_prefix_case_sensitive(self, mock_bot, mock_message):
"""Test that prefix matching is case-sensitive"""
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
for content in ("!test", "!TEST"):
msg = _msg(content)
assert manager.normalize_command_content(msg) is True
assert command.matches_keyword(msg) is True
mock_bot.config.set('Bot', 'command_prefix', 'b')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
assert manager.normalize_command_content(_msg("Btest")) is False
def test_empty_prefix_string(self, mock_bot, mock_message):
"""Test that empty string prefix means no prefix required"""
mock_bot.config.set('Bot', 'command_prefix', '')
manager = _make_manager(mock_bot)
command = MockTestCommand(mock_bot)
plain = _msg("test")
assert manager.normalize_command_content(plain) is True
assert command.matches_keyword(plain) is True
legacy = _msg("!test")
assert manager.normalize_command_content(legacy) is True
assert command.matches_keyword(legacy) is True
def test_normalize_is_idempotent(self, mock_bot, mock_message):
mock_bot.config.set('Bot', 'command_prefix', '!')
manager = _make_manager(mock_bot)
mock_message.content = "!path ab"
assert manager.normalize_command_content(mock_message) is True
assert mock_message.content == "path ab"
assert manager.normalize_command_content(mock_message) is True
assert mock_message.content == "path ab"
@pytest.mark.asyncio
async def test_execute_command_runs_after_check_keywords_with_prefix(self, mock_bot, mock_message):
"""Regression: execute()-based commands must run when command_prefix is set."""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.set('Bot', 'respond_to_mentions', 'false')
manager = _make_manager(mock_bot)
mock_bot.command_manager = manager
path_cmd = ExecuteOnlyCommand(mock_bot)
path_cmd.execute = AsyncMock(return_value=True)
path_cmd.send_response = AsyncMock(return_value=True)
manager.commands['path'] = path_cmd
mock_message.content = "!path ab"
matches = manager.check_keywords(mock_message)
assert ('path', None) in matches
await manager.execute_commands(mock_message)
path_cmd.execute.assert_called_once()
@pytest.mark.asyncio
async def test_response_format_command_still_works_with_prefix(self, mock_bot, mock_message):
"""Commands with response formats are handled in check_keywords."""
mock_bot.config.set('Bot', 'command_prefix', '!')
mock_bot.config.add_section('Ping_Command')
mock_bot.config.set('Ping_Command', 'enabled', 'true')
mock_bot.config.add_section('Keywords')
mock_bot.config.set('Keywords', 'ping', 'Pong!')
ping_cmd = PingCommand(mock_bot)
manager = _make_manager(mock_bot, commands={'ping': ping_cmd})
mock_bot.command_manager = manager
mock_message.content = "!ping"
matches = manager.check_keywords(mock_message)
assert any(name == 'ping' and response == 'Pong!' for name, response in matches)