Files
meshcore-bot/tests/test_help_command.py
agessaman 219de10afd Refactor command alias handling to standardize command prefix use
- Updated configuration examples to remove command prefix from aliases in `config.ini.example` and documentation.
- Enhanced `CommandManager` to normalize command names and resolve aliases through both direct mapping and plugin keyword mappings.
- Introduced a method in `BaseCommand` to normalize aliases from configuration, ensuring consistency in keyword handling.
- Added tests to verify that aliases resolve correctly from both keyword mappings and runtime keywords without legacy prefixes.
2026-03-19 16:39:08 -07:00

482 lines
18 KiB
Python

"""Tests for modules.commands.help_command — pure logic and integration paths."""
import configparser
from contextlib import contextmanager
from unittest.mock import MagicMock, Mock
from modules.commands.help_command import HelpCommand
from tests.conftest import mock_message
# ---------------------------------------------------------------------------
# Bot factory
# ---------------------------------------------------------------------------
def _make_bot(enabled=True, commands=None):
"""Create a minimal mock bot for HelpCommand tests."""
bot = MagicMock()
bot.logger = Mock()
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "bot_name", "TestBot")
config.add_section("Channels")
config.set("Channels", "monitor_channels", "general")
config.set("Channels", "respond_to_dms", "true")
config.add_section("Keywords")
if enabled:
config.add_section("Help_Command")
config.set("Help_Command", "enabled", "true")
bot.config = config
bot.translator = MagicMock()
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
bot.translator.get_value = Mock(return_value=None)
bot.command_manager = MagicMock()
bot.command_manager.monitor_channels = ["general"]
bot.command_manager.send_response = MagicMock()
# Default empty commands dict
if commands is None:
bot.command_manager.commands = {}
else:
bot.command_manager.commands = commands
# Plugin loader with keyword_mappings
bot.command_manager.plugin_loader = MagicMock()
bot.command_manager.plugin_loader.keyword_mappings = {}
# DB manager with in-memory SQLite
import sqlite3
conn = sqlite3.connect(":memory:")
db = MagicMock()
@contextmanager
def _conn_ctx():
yield conn
db.connection = _conn_ctx
db.db_path = ":memory:"
bot.db_manager = db
return bot
# ---------------------------------------------------------------------------
# _format_commands_list_to_length (pure logic)
# ---------------------------------------------------------------------------
class TestFormatCommandsListToLength:
def setup_method(self):
self.cmd = HelpCommand(_make_bot())
def test_no_max_length_returns_all(self):
result = self.cmd._format_commands_list_to_length(["a", "b", "c"])
assert result == "a, b, c"
def test_max_length_zero_returns_all(self):
result = self.cmd._format_commands_list_to_length(["a", "b", "c"], max_length=0)
assert result == "a, b, c"
def test_empty_list_returns_empty(self):
result = self.cmd._format_commands_list_to_length([])
assert result == ""
def test_truncates_at_max_length(self):
# "a, b, c" = 7 chars; limit to 4 → only "a" + " (2 more)"
result = self.cmd._format_commands_list_to_length(["a", "b", "c"], max_length=10)
# Just verify it doesn't exceed max_length
assert len(result) <= 10
def test_all_fit_within_max_length(self):
result = self.cmd._format_commands_list_to_length(["ping", "wx"], max_length=100)
assert result == "ping, wx"
def test_suffix_appended_when_truncated(self):
names = ["alpha", "beta", "gamma", "delta", "epsilon"]
result = self.cmd._format_commands_list_to_length(names, max_length=20)
# Should contain "(N more)" suffix or be truncated
assert len(result) <= 20 or "more" in result
def test_single_item(self):
result = self.cmd._format_commands_list_to_length(["ping"])
assert result == "ping"
def test_single_item_exceeds_max_length(self):
result = self.cmd._format_commands_list_to_length(["verylongcommandname"], max_length=5)
# Can't fit, returns empty or truncated
assert isinstance(result, str)
def test_negative_max_length_returns_all(self):
result = self.cmd._format_commands_list_to_length(["a", "b"], max_length=-1)
assert result == "a, b"
# ---------------------------------------------------------------------------
# _is_command_valid_for_channel
# ---------------------------------------------------------------------------
class TestIsCommandValidForChannel:
def setup_method(self):
self.bot = _make_bot()
self.cmd = HelpCommand(self.bot)
def test_no_message_always_true(self):
mock_cmd = MagicMock()
assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, None) is True
def test_channel_allowed_returns_true(self):
mock_cmd = MagicMock()
mock_cmd.is_channel_allowed = Mock(return_value=True)
msg = mock_message(content="help", channel="general")
assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is True
def test_channel_not_allowed_returns_false(self):
mock_cmd = MagicMock()
mock_cmd.is_channel_allowed = Mock(return_value=False)
msg = mock_message(content="help", channel="general")
assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is False
def test_no_is_channel_allowed_attribute(self):
mock_cmd = MagicMock(spec=[]) # No attributes
msg = mock_message(content="help", channel="general")
# Should not crash, should check _is_channel_trigger_allowed
result = self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg)
assert isinstance(result, bool)
def test_channel_trigger_not_allowed(self):
mock_cmd = MagicMock()
mock_cmd.is_channel_allowed = Mock(return_value=True)
self.bot.command_manager._is_channel_trigger_allowed = Mock(return_value=False)
msg = mock_message(content="help", channel="restricted")
assert self.cmd._is_command_valid_for_channel("restricted_cmd", mock_cmd, msg) is False
def test_channel_trigger_allowed(self):
mock_cmd = MagicMock()
mock_cmd.is_channel_allowed = Mock(return_value=True)
self.bot.command_manager._is_channel_trigger_allowed = Mock(return_value=True)
msg = mock_message(content="help", channel="general")
assert self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg) is True
def test_no_channel_trigger_check_attribute(self):
"""When command_manager lacks _is_channel_trigger_allowed, still works."""
mock_cmd = MagicMock()
mock_cmd.is_channel_allowed = Mock(return_value=True)
del self.bot.command_manager._is_channel_trigger_allowed
msg = mock_message(content="help", channel="general")
result = self.cmd._is_command_valid_for_channel("ping", mock_cmd, msg)
assert result is True
# ---------------------------------------------------------------------------
# get_specific_help
# ---------------------------------------------------------------------------
class TestGetSpecificHelp:
def test_known_command_with_help_text(self):
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.get_help_text = Mock(return_value="Ping the bot")
bot.command_manager.commands = {"ping": mock_ping}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("ping")
assert "commands.help.specific" in result or result != ""
def test_known_command_help_text_no_message_param(self):
"""Falls back to no-argument get_help_text when TypeError is raised."""
bot = _make_bot()
mock_cmd = MagicMock()
mock_cmd.get_help_text = Mock(side_effect=[TypeError("no param"), "Simple help"])
bot.command_manager.commands = {"foo": mock_cmd}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("foo")
assert isinstance(result, str)
def test_unknown_command_returns_unknown_key(self):
bot = _make_bot()
bot.command_manager.commands = {}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("unknowncmd")
assert "commands.help.unknown" in result
def test_alias_mapping_applied(self):
"""Alias 'ping' maps to itself."""
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.get_help_text = Mock(return_value="Pong!")
bot.command_manager.commands = {"ping": mock_ping}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("ping")
assert isinstance(result, str)
def test_alias_from_keyword_mappings_resolves(self):
bot = _make_bot()
mock_schedule = MagicMock()
mock_schedule.get_help_text = Mock(return_value="Schedule help")
mock_schedule.keywords = ["schedule"]
bot.command_manager.commands = {"schedule": mock_schedule}
bot.command_manager.plugin_loader.keyword_mappings = {"sched": "schedule"}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("sched")
assert isinstance(result, str)
def test_alias_from_runtime_keywords_resolves_without_mapping(self):
bot = _make_bot()
mock_schedule = MagicMock()
mock_schedule.get_help_text = Mock(return_value="Schedule help")
mock_schedule.keywords = ["schedule", "sched"]
bot.command_manager.commands = {"schedule": mock_schedule}
bot.command_manager.plugin_loader.keyword_mappings = {}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("sched")
assert isinstance(result, str)
def test_no_get_help_text_attribute(self):
"""Command without get_help_text returns no_help key."""
bot = _make_bot()
mock_cmd = MagicMock(spec=[])
bot.command_manager.commands = {"bare": mock_cmd}
cmd = HelpCommand(bot)
result = cmd.get_specific_help("bare")
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# can_execute
# ---------------------------------------------------------------------------
class TestCanExecute:
def test_enabled_true(self):
bot = _make_bot(enabled=True)
cmd = HelpCommand(bot)
msg = mock_message(content="help", channel="general")
assert cmd.can_execute(msg) is True
def test_enabled_false(self):
bot = _make_bot()
# Set the flag directly — the config section already exists from _make_bot
bot.config.set("Help_Command", "enabled", "false")
cmd = HelpCommand(bot)
cmd.help_enabled = False
msg = mock_message(content="help", channel="general")
assert cmd.can_execute(msg) is False
# ---------------------------------------------------------------------------
# get_help_text
# ---------------------------------------------------------------------------
class TestGetHelpText:
def test_returns_string(self):
cmd = HelpCommand(_make_bot())
result = cmd.get_help_text()
assert isinstance(result, str)
class TestGetGeneralHelp:
"""Tests for get_general_help() (lines 134-138)."""
def test_returns_string(self):
bot = _make_bot()
cmd = HelpCommand(bot)
result = cmd.get_general_help()
assert isinstance(result, str)
def test_includes_commands_help_key(self):
bot = _make_bot()
cmd = HelpCommand(bot)
result = cmd.get_general_help()
# Our mock translator returns keys — so should contain 'commands.help.general'
assert "commands.help" in result
class TestGetAvailableCommandsListFiltered:
"""Tests for channel-filtered command listing (line 185)."""
def test_channel_filter_excludes_invalid_commands(self):
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.name = "ping"
mock_ping.is_channel_allowed = Mock(return_value=False) # Excluded
mock_wx = MagicMock()
mock_wx.name = "wx"
mock_wx.is_channel_allowed = Mock(return_value=True) # Included
bot.command_manager.commands = {"ping": mock_ping, "wx": mock_wx}
bot.command_manager._is_channel_trigger_allowed = Mock(return_value=True)
cmd = HelpCommand(bot)
msg = mock_message(content="help", channel="general")
result = cmd.get_available_commands_list(message=msg)
# ping is excluded; wx should be present
assert "wx" in result or "commands.help" in result # or just no crash
def test_command_in_stats_not_in_keyword_mappings(self):
"""Commands returned from DB stats but not in keyword_mappings (lines 218-235)."""
import sqlite3
from contextlib import contextmanager
bot = _make_bot()
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE command_stats (
id INTEGER PRIMARY KEY,
timestamp INTEGER,
sender_id TEXT,
command_name TEXT,
channel TEXT,
is_dm BOOLEAN,
response_sent BOOLEAN
)
""")
# Add a command that's NOT in keyword_mappings but IS a primary command name
conn.execute("INSERT INTO command_stats VALUES (1, 1, 'u', 'unknown_cmd', 'g', 0, 1)")
conn.commit()
db = MagicMock()
@contextmanager
def _conn_ctx():
yield conn
db.connection = _conn_ctx
bot.db_manager = db
mock_cmd = MagicMock()
mock_cmd.name = "known"
bot.command_manager.commands = {"known": mock_cmd}
bot.command_manager.plugin_loader.keyword_mappings = {}
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list()
assert isinstance(result, str)
class TestFormatCommandsListSuffix:
"""Tests for _format_commands_list_to_length with suffix that fits (line 294)."""
def test_suffix_fits_within_max(self):
cmd = HelpCommand(_make_bot())
# "ping" = 4 chars, " (1 more)" = 9 chars, "wx" doesn't fit; total "ping (1 more)" = 13
result = cmd._format_commands_list_to_length(["ping", "wx"], max_length=13)
assert "(1 more)" in result or "ping" in result
def test_suffix_appended_when_some_fit(self):
cmd = HelpCommand(_make_bot())
names = ["ab", "cd", "ef", "gh"]
result = cmd._format_commands_list_to_length(names, max_length=8)
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# execute
# ---------------------------------------------------------------------------
class TestExecute:
def test_execute_returns_true(self):
import asyncio
bot = _make_bot()
cmd = HelpCommand(bot)
msg = mock_message(content="help", channel="general")
result = asyncio.run(cmd.execute(msg))
assert result is True
# ---------------------------------------------------------------------------
# get_available_commands_list
# ---------------------------------------------------------------------------
class TestGetAvailableCommandsList:
def test_empty_commands_returns_empty(self):
bot = _make_bot()
bot.command_manager.commands = {}
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list()
assert isinstance(result, str)
def test_with_commands_returns_names(self):
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.name = "ping"
bot.command_manager.commands = {"ping": mock_ping}
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list()
assert "ping" in result
def test_with_max_length(self):
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.name = "ping"
bot.command_manager.commands = {"ping": mock_ping}
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list(max_length=3)
assert len(result) <= 3 or "ping" in result
def test_with_message_filter(self):
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.name = "ping"
mock_ping.is_channel_allowed = Mock(return_value=True)
bot.command_manager.commands = {"ping": mock_ping}
cmd = HelpCommand(bot)
msg = mock_message(content="help", channel="general")
result = cmd.get_available_commands_list(message=msg)
assert isinstance(result, str)
def test_with_stats_table_present(self):
"""When command_stats table exists, commands are sorted by usage count."""
import sqlite3
from contextlib import contextmanager
bot = _make_bot()
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE command_stats (
id INTEGER PRIMARY KEY,
timestamp INTEGER,
sender_id TEXT,
command_name TEXT,
channel TEXT,
is_dm BOOLEAN,
response_sent BOOLEAN
)
""")
conn.execute("INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) VALUES (1, 'u1', 'ping', 'general', 0, 1)")
conn.execute("INSERT INTO command_stats (timestamp, sender_id, command_name, channel, is_dm, response_sent) VALUES (2, 'u1', 'ping', 'general', 0, 1)")
conn.commit()
db = MagicMock()
@contextmanager
def _conn_ctx():
yield conn
db.connection = _conn_ctx
bot.db_manager = db
mock_ping = MagicMock()
mock_ping.name = "ping"
bot.command_manager.commands = {"ping": mock_ping}
bot.command_manager.plugin_loader.keyword_mappings = {"ping": "ping"}
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list()
assert "ping" in result
def test_db_exception_falls_back_gracefully(self):
"""If DB raises, falls back to sorted command names."""
bot = _make_bot()
mock_ping = MagicMock()
mock_ping.name = "ping"
bot.command_manager.commands = {"ping": mock_ping}
bad_db = MagicMock()
@contextmanager
def _bad_conn():
raise Exception("DB down")
yield # noqa: unreachable
bad_db.connection = _bad_conn
bot.db_manager = bad_db
cmd = HelpCommand(bot)
result = cmd.get_available_commands_list()
assert isinstance(result, str)