2026-06-17 01:48:56 +02:00
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.config import Settings
|
|
|
|
|
from app.errors import UnknownComponentError
|
2026-06-17 02:14:25 +02:00
|
|
|
from app.store import SessionOwnershipError
|
2026-06-17 01:48:56 +02:00
|
|
|
from app.dependencies import (
|
|
|
|
|
resolve_route,
|
|
|
|
|
get_llm_provider,
|
|
|
|
|
get_tts_provider,
|
2026-06-17 02:14:25 +02:00
|
|
|
get_store,
|
2026-06-17 01:48:56 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 02:14:25 +02:00
|
|
|
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"
|
2026-06-17 01:48:56 +02:00
|
|
|
assert route.language == "en"
|
|
|
|
|
|
2026-06-17 02:14:25 +02:00
|
|
|
# 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")
|
2026-06-17 01:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-06-25 00:32:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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"),
|
|
|
|
|
("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.
|
|
|
|
|
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"
|
2026-06-25 02:51:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|