import pytest from app.config import Settings from app.errors import UnknownComponentError from app.store import SessionOwnershipError from app.dependencies import ( resolve_route, get_llm_provider, get_tts_provider, get_store, ) def test_default_route_from_settings(): cfg = Settings() route = resolve_route(cfg=cfg) assert route.stt_provider == cfg.default_stt_provider assert route.llm_provider == cfg.default_llm_provider assert route.input_endpoint == cfg.default_input_endpoint assert route.language == cfg.default_language def test_request_overrides_win(): route = resolve_route(overrides={"llm_provider": "openrouter", "output_endpoint": "loopback"}) assert route.llm_provider == "openrouter" assert route.output_endpoint == "loopback" def test_user_prefs_session_request_precedence(): store = get_store() user, _ = store.create_user("Tester") store.set_user_prefs(user.id, {"tts_provider": "chatterbox", "language": "en"}) user = store.get_user(user.id) # Nutzer-Prefs gelten. route = resolve_route(user) assert route.tts_provider == "chatterbox" assert route.language == "en" # Session schlägt Nutzer-Prefs. store.update_session("s1", user.id, {"tts_provider": "piper"}) route2 = resolve_route(user, "s1") assert route2.tts_provider == "piper" assert route2.language == "en" # weiterhin aus den Nutzer-Prefs # Request schlägt Session. route3 = resolve_route(user, "s1", {"tts_provider": "openrouter"}) assert route3.tts_provider == "openrouter" def test_session_ownership_enforced_in_route(): store = get_store() owner, _ = store.create_user("A") other, _ = store.create_user("B") store.update_session("shared", owner.id, {"tts_provider": "piper"}) with pytest.raises(SessionOwnershipError): resolve_route(other, "shared") def test_registry_unknown_provider_raises(): with pytest.raises(UnknownComponentError): get_llm_provider("does-not-exist") def test_registry_known_provider(): assert type(get_tts_provider("piper")).__name__ == "PiperTTSProvider" # --------------------------------------------------------------------------- # voice_for_route: Stimm-/Geschlechts-Auswahl je Provider und Sprache. # --------------------------------------------------------------------------- from app.dependencies import voice_for_route, PIPER_VOICE_GENDERED @pytest.mark.parametrize("lang,gender,expected", [ ("de", "f", "de_DE-kerstin-low"), ("de", "m", "de_DE-thorsten-high"), ("en", "f", "en_US-amy-medium"), ("en", "m", "en_US-lessac-high"), ("fr", "f", "fr_FR-siwis-medium"), ("fr", "m", "fr_FR-tom-medium"), ("es", "f", "es_ES-sharvard-medium#1"), # Multi-Speaker: #1 = weiblich ("es", "m", "es_ES-sharvard-medium#0"), # Multi-Speaker: #0 = maennlich ("it", "f", "it_IT-paola-medium"), ("it", "m", "it_IT-riccardo-x_low"), ("ru", "f", "ru_RU-irina-medium"), ("ru", "m", "ru_RU-ruslan-medium"), ("pl", "f", "pl_PL-gosia-medium"), ("pl", "m", "pl_PL-darkman-medium"), ]) def test_piper_gendered_voice_exact(lang, gender, expected): assert voice_for_route("piper", lang, gender) == expected @pytest.mark.parametrize("lang,expected", [ ("ar", "ar_JO-kareem-medium"), ("pt", "pt_BR-faber-medium"), ("nl", "nl_NL-mls-medium"), ("zh", "zh_CN-huayan-medium"), ]) def test_piper_single_voice_languages_ignore_gender(lang, expected): # Diese Sprachen haben keinen Geschlechtspartner -> selbe Stimme fuer m und f. # Wichtig fuer NL: die m/f-Wahl greift hier folgenlos (einzig verfuegbare Stimme); # die Geraete-TTS darf deshalb nicht auf Server-Fallback ausweichen (s. app.js # deviceVoiceReady). Ohne diesen Eintrag wuerde voice_for_route None liefern und # den Server-Default (deutsche Stimme) nach sich ziehen. assert voice_for_route("piper", lang, "f") == expected assert voice_for_route("piper", lang, "m") == expected def test_piper_any_prefers_female(): # 'any' (und unbekannte Codes) bevorzugen weiblich, konsistent mit Cartesia. assert voice_for_route("piper", "de", "any") == "de_DE-kerstin-low" assert voice_for_route("piper", "fr", "any") == "fr_FR-siwis-medium" def test_piper_unknown_language_returns_none(): assert voice_for_route("piper", "xx", "f") is None def test_piper_without_language_returns_none(): assert voice_for_route("piper", None, "f") is None def test_cartesia_passes_gender_code_through(): # Cartesia loest die UUID selbst auf -> nur der Gender-Code wird durchgereicht. assert voice_for_route("cartesia", "de", "f") == "f" assert voice_for_route("cartesia", "de", "any") == "any" def test_other_provider_returns_none(): assert voice_for_route("openrouter", "de", "f") is None def test_piper_falls_back_to_opposite_gender(monkeypatch): # Fehlt die angeforderte Richtung, wird die vorhandene Gegenrichtung genommen # (vor dem gender-agnostischen LANG_TO_PIPER_VOICE-Fallback). monkeypatch.delitem(PIPER_VOICE_GENDERED, ("de", "m"), raising=False) assert voice_for_route("piper", "de", "m") == "de_DE-kerstin-low" def test_route_has_no_language_mode(): # Flex-Modus wurde entfernt: die Route trägt kein language_mode mehr. route = resolve_route() assert "language_mode" not in route.as_dict() assert not hasattr(route, "language_mode") def test_route_clamps_language_to_allowed(): store = get_store() user, _ = store.create_user("Senior") store.set_user_prefs(user.id, {"allowed_languages": "en,fr", "language": "de"}) user = store.get_user(user.id) # de ist nicht erlaubt -> auf erste erlaubte Sprache (en) geklemmt assert resolve_route(user).language == "en" def test_route_keeps_allowed_language(): store = get_store() user, _ = store.create_user("Senior2") store.set_user_prefs(user.id, {"allowed_languages": "en,fr", "language": "fr"}) user = store.get_user(user.id) assert resolve_route(user).language == "fr"