Some checks failed
social-bot integration tests / Lint (flake8 + pep257) (push) Failing after 2s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (push) Has been skipped
social-bot integration tests / Lint (flake8 + pep257) (pull_request) Failing after 10s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (pull_request) Has been skipped
social-bot integration tests / Latency profiling (GPU, Orin) (push) Has been cancelled
social-bot integration tests / Latency profiling (GPU, Orin) (pull_request) Has been cancelled
- Add SpeechTranscript.language (BCP-47), ConversationResponse.language fields
- speech_pipeline_node: whisper_language param (""=auto-detect via Whisper LID);
detected language published in every transcript
- conversation_node: track per-speaker language; inject "[Please respond in X.]"
hint for non-English speakers; propagate language to ConversationResponse.
_LANG_NAMES: 24 BCP-47 codes -> English names. Also adds Issue #161 emotion
context plumbing (co-located in same branch for clean merge)
- tts_node: voice_map_json param (JSON BCP-47->ONNX path); lazy voice loading
per language; playback queue now carries (text, lang) tuples for voice routing
- speech_params.yaml, tts_params.yaml: new language params with docs
- 47/47 tests pass (test_multilang.py)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 lines
5.6 KiB
Python
123 lines
5.6 KiB
Python
"""test_multilang.py -- Unit tests for Issue #167 multi-language support."""
|
|
|
|
from __future__ import annotations
|
|
import json, os
|
|
from typing import Any, Dict, Optional
|
|
import pytest
|
|
|
|
def _pkg_root():
|
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
def _read_src(rel_path):
|
|
with open(os.path.join(_pkg_root(), rel_path)) as f:
|
|
return f.read()
|
|
|
|
def _extract_lang_names():
|
|
import ast
|
|
src = _read_src("saltybot_social/conversation_node.py")
|
|
start = src.index("_LANG_NAMES: Dict[str, str] = {")
|
|
end = src.index("\n}", start) + 2
|
|
return ast.literal_eval(src[start:end].split("=",1)[1].strip())
|
|
|
|
class TestLangNames:
|
|
@pytest.fixture(scope="class")
|
|
def ln(self): return _extract_lang_names()
|
|
def test_english(self, ln): assert ln["en"] == "English"
|
|
def test_french(self, ln): assert ln["fr"] == "French"
|
|
def test_spanish(self, ln): assert ln["es"] == "Spanish"
|
|
def test_german(self, ln): assert ln["de"] == "German"
|
|
def test_japanese(self, ln):assert ln["ja"] == "Japanese"
|
|
def test_chinese(self, ln): assert ln["zh"] == "Chinese"
|
|
def test_arabic(self, ln): assert ln["ar"] == "Arabic"
|
|
def test_at_least_15(self, ln): assert len(ln) >= 15
|
|
def test_lowercase_keys(self, ln):
|
|
for k in ln: assert k == k.lower() and 2 <= len(k) <= 3
|
|
def test_nonempty_values(self, ln):
|
|
for k, v in ln.items(): assert v
|
|
|
|
class TestLanguageHint:
|
|
def _h(self, sl, sid, ln):
|
|
lang = sl.get(sid, "en")
|
|
return f"[Please respond in {ln.get(lang, lang)}.]" if lang and lang != "en" else ""
|
|
@pytest.fixture(scope="class")
|
|
def ln(self): return _extract_lang_names()
|
|
def test_english_no_hint(self, ln): assert self._h({"p": "en"}, "p", ln) == ""
|
|
def test_unknown_no_hint(self, ln): assert self._h({}, "p", ln) == ""
|
|
def test_french(self, ln): assert self._h({"p":"fr"},"p",ln) == "[Please respond in French.]"
|
|
def test_spanish(self, ln): assert self._h({"p":"es"},"p",ln) == "[Please respond in Spanish.]"
|
|
def test_unknown_code(self, ln): assert "xx" in self._h({"p":"xx"},"p",ln)
|
|
def test_brackets(self, ln):
|
|
h = self._h({"p":"de"},"p",ln)
|
|
assert h.startswith("[") and h.endswith("]")
|
|
|
|
class TestVoiceMap:
|
|
def _parse(self, jstr, dl, dp):
|
|
try: extra = json.loads(jstr) if jstr.strip() not in ("{}","") else {}
|
|
except: extra = {}
|
|
r = {dl: dp}; r.update(extra); return r
|
|
def test_empty(self): assert self._parse("{}","en","/e") == {"en":"/e"}
|
|
def test_extra(self):
|
|
vm = self._parse('{"fr":"/f"}', "en", "/e")
|
|
assert vm["fr"] == "/f"
|
|
def test_invalid(self): assert self._parse("BAD","en","/e") == {"en":"/e"}
|
|
def test_multi(self):
|
|
assert len(self._parse(json.dumps({"fr":"/f","es":"/s"}),"en","/e")) == 3
|
|
|
|
class TestVoiceSelect:
|
|
def _s(self, voices, lang, default):
|
|
return voices.get(lang) or voices.get(default)
|
|
def test_exact(self): assert self._s({"en":"E","fr":"F"},"fr","en") == "F"
|
|
def test_fallback(self): assert self._s({"en":"E"},"fr","en") == "E"
|
|
def test_none(self): assert self._s({},"fr","en") is None
|
|
|
|
class TestSttFields:
|
|
@pytest.fixture(scope="class")
|
|
def src(self): return _read_src("saltybot_social/speech_pipeline_node.py")
|
|
def test_param(self, src): assert "whisper_language" in src
|
|
def test_detected_lang(self, src): assert "detected_lang" in src
|
|
def test_msg_language(self, src): assert "msg.language = language" in src
|
|
def test_auto_detect(self, src): assert "language=self._whisper_language" in src
|
|
|
|
class TestConvFields:
|
|
@pytest.fixture(scope="class")
|
|
def src(self): return _read_src("saltybot_social/conversation_node.py")
|
|
def test_speaker_lang(self, src): assert "_speaker_lang" in src
|
|
def test_lang_hint_method(self, src): assert "_language_hint" in src
|
|
def test_msg_language(self, src): assert "msg.language = language" in src
|
|
def test_lang_names(self, src): assert "_LANG_NAMES" in src
|
|
def test_please_respond(self, src): assert "Please respond in" in src
|
|
def test_emotion_coexists(self, src): assert "_emotion_hint" in src
|
|
|
|
class TestTtsFields:
|
|
@pytest.fixture(scope="class")
|
|
def src(self): return _read_src("saltybot_social/tts_node.py")
|
|
def test_voice_map_json(self, src): assert "voice_map_json" in src
|
|
def test_default_lang(self, src): assert "default_language" in src
|
|
def test_voices_dict(self, src): assert "_voices" in src
|
|
def test_get_voice(self, src): assert "_get_voice" in src
|
|
def test_load_voice_for_lang(self, src): assert "_load_voice_for_lang" in src
|
|
def test_queue_tuple(self, src): assert "(text.strip(), lang)" in src
|
|
def test_synthesize_voice_arg(self, src): assert "_synthesize(text, voice)" in src
|
|
|
|
class TestMsgDefs:
|
|
@pytest.fixture(scope="class")
|
|
def tr(self): return _read_src("../saltybot_social_msgs/msg/SpeechTranscript.msg")
|
|
@pytest.fixture(scope="class")
|
|
def re(self): return _read_src("../saltybot_social_msgs/msg/ConversationResponse.msg")
|
|
def test_transcript_lang(self, tr): assert "string language" in tr
|
|
def test_transcript_bcp47(self, tr): assert "BCP-47" in tr
|
|
def test_response_lang(self, re): assert "string language" in re
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_lang_no_hint(self):
|
|
lang = "" or "en"; assert lang == "en"
|
|
def test_lang_flows(self):
|
|
d: Dict[str,str] = {}; d["p1"] = "fr"
|
|
assert d.get("p1","en") == "fr"
|
|
def test_multi_speakers(self):
|
|
d = {"p1":"fr","p2":"es"}
|
|
assert d["p1"] == "fr" and d["p2"] == "es"
|
|
def test_voice_map_code_in_tts(self):
|
|
src = _read_src("saltybot_social/tts_node.py")
|
|
assert "voice_map_json" in src and "json.loads" in src
|