mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-17 07:59:56 +00:00
Reworked implementation of the multilingual-response feature (issue #218) as a reusable, framework-level capability instead of logic bolted onto HelloCommand. - modules/lang_detector.py: keyword-first detection (reliable for the short greetings typical on mesh) with optional langdetect for longer text; results are constrained to languages that actually have a translation file. - core.py: cache Translator instances by language (get_translator) so per-message switching never re-reads files, and expose available_languages() derived from translations/*.json. Store translation_path; keep cache/path consistent on config reload. - base_command.py: task-local translator override via a ContextVar (safe under asyncio concurrency, no global mutation), plus detect_response_language() and a respond_in_sender_language() context manager any command can opt into. - hello_command.py: build the reply inside respond_in_sender_language(); the only awaited call (send_response) stays outside the override window. - Gated by [Localization] auto_detect_language (default false); langdetect is an optional [lang] extra, not a hard dependency. Tests: unit coverage for the detector and the base-command wiring.
124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
"""Tests for BaseCommand language auto-detection and the response-language
|
|
context manager (modules.commands.base_command)."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from modules.commands.base_command import BaseCommand
|
|
from tests.conftest import mock_message
|
|
|
|
|
|
class _FakeTranslator:
|
|
"""Minimal translator that tags keys with its language for assertions."""
|
|
|
|
def __init__(self, language):
|
|
self.language = language
|
|
self.base_language = language.split("-")[0]
|
|
|
|
def translate(self, key, **kwargs):
|
|
return f"[{self.language}] {key}"
|
|
|
|
def get_value(self, key):
|
|
return None
|
|
|
|
|
|
class _ProbeCommand(BaseCommand):
|
|
"""Concrete command exposing translate() for the tests."""
|
|
|
|
name = "probe"
|
|
keywords = ["probe"]
|
|
|
|
async def execute(self, message): # pragma: no cover - not exercised here
|
|
return True
|
|
|
|
|
|
def _make_command(command_mock_bot, *, enabled, current="en"):
|
|
"""Wire the mock bot with real translator-caching hooks and build a command."""
|
|
if not command_mock_bot.config.has_section("Localization"):
|
|
command_mock_bot.config.add_section("Localization")
|
|
command_mock_bot.config.set(
|
|
"Localization", "auto_detect_language", "true" if enabled else "false"
|
|
)
|
|
command_mock_bot.translator = _FakeTranslator(current)
|
|
command_mock_bot.available_languages = lambda: {"en", "de", "fr", "it"}
|
|
command_mock_bot.get_translator = lambda lang: _FakeTranslator(lang)
|
|
return _ProbeCommand(command_mock_bot)
|
|
|
|
|
|
class TestDetectResponseLanguage:
|
|
def test_disabled_returns_none(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=False)
|
|
msg = mock_message(content="ciao")
|
|
assert cmd.detect_response_language(msg) is None
|
|
|
|
def test_enabled_detects_different_language(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True)
|
|
msg = mock_message(content="ciao")
|
|
assert cmd.detect_response_language(msg) == "it"
|
|
|
|
def test_same_as_default_returns_none(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True)
|
|
msg = mock_message(content="hello there") # english == default
|
|
assert cmd.detect_response_language(msg) is None
|
|
|
|
def test_unsupported_language_returns_none(self, command_mock_bot):
|
|
# Spanish is detectable by keyword but has no file in available_languages.
|
|
cmd = _make_command(command_mock_bot, enabled=True)
|
|
msg = mock_message(content="hola")
|
|
assert cmd.detect_response_language(msg) is None
|
|
|
|
def test_mentions_are_stripped_before_detection(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True)
|
|
msg = mock_message(content="@[TestBot] hallo")
|
|
assert cmd.detect_response_language(msg) == "de"
|
|
|
|
def test_empty_content_returns_none(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True)
|
|
msg = mock_message(content="")
|
|
assert cmd.detect_response_language(msg) is None
|
|
|
|
|
|
class TestRespondInSenderLanguage:
|
|
def test_binds_translator_inside_and_restores_after(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True, current="en")
|
|
msg = mock_message(content="ciao")
|
|
|
|
# Outside the block: default (English) translator.
|
|
assert cmd.translate("k") == "[en] k"
|
|
|
|
with cmd.respond_in_sender_language(msg):
|
|
assert cmd.translate("k") == "[it] k"
|
|
|
|
# Restored afterwards.
|
|
assert cmd.translate("k") == "[en] k"
|
|
|
|
def test_no_switch_keeps_default(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True, current="en")
|
|
msg = mock_message(content="hello") # english, no switch
|
|
|
|
with cmd.respond_in_sender_language(msg):
|
|
assert cmd.translate("k") == "[en] k"
|
|
|
|
def test_disabled_keeps_default_inside_block(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=False, current="en")
|
|
msg = mock_message(content="ciao")
|
|
|
|
with cmd.respond_in_sender_language(msg):
|
|
assert cmd.translate("k") == "[en] k"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_overrides_are_task_local(self, command_mock_bot):
|
|
cmd = _make_command(command_mock_bot, enabled=True, current="en")
|
|
|
|
async def render(content):
|
|
with cmd.respond_in_sender_language(mock_message(content=content)):
|
|
await asyncio.sleep(0)
|
|
return cmd.translate("k")
|
|
|
|
italian, german = await asyncio.gather(render("ciao"), render("hallo"))
|
|
|
|
assert italian == "[it] k"
|
|
assert german == "[de] k"
|
|
assert cmd.translate("k") == "[en] k"
|