Files
pony/web/tests/test_voice.py
alex 88ef6f11eb feat: add voice interaction, TTS, fullscreen, and dice improvements
- Add voice button and voice flow services for speech interaction
- Add text-to-speech (TTS) service and narration
- Add fullscreen button component
- Update dice logic with hardened numDice fallback
- Add voice integration tests
- Update themes, game routes, and intent classifier
- Add developer notes and QA directory
2026-08-09 18:02:16 +00:00

220 lines
8.0 KiB
Python

"""Voice normalization, matching, and API integration tests."""
import os
import sys
import tempfile
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.config import create_app
from app.game.voice_matcher import match_intent
from app.game.voice_normalizer import normalize_danish
from app.services import persistence
from app.services.speech_to_text import LocalFasterWhisperProvider, get_speech_to_text_provider
INTENTS = [
{"id": "forest", "keywords": ["skov", "skoven", "træer"], "synonyms": ["mellem træerne"]},
{"id": "garden", "keywords": ["have", "haven", "blomster"]},
]
def make_client():
persistence.DB_PATH = tempfile.mktemp()
app = create_app()
app.config["TESTING"] = True
return app.test_client()
def test_normalizer_preserves_danish_letters():
assert normalize_danish(" SKOVEN!! ÆØÅ ") == "skoven æøå"
def test_local_stt_provider_can_be_selected(monkeypatch):
monkeypatch.setenv("STT_PROVIDER", "local")
monkeypatch.setenv("STT_MODEL", "test-model")
provider = get_speech_to_text_provider()
assert isinstance(provider, LocalFasterWhisperProvider)
assert provider.model_name == "test-model"
def test_matches_whole_word_in_child_sentence():
result = match_intent("Jeg tror den er ved træer!", INTENTS)
assert result.matched
assert result.intent == "forest"
assert result.method == "keyword"
def test_does_not_use_naive_substring_matching():
result = match_intent("Vi skal behøve noget", [{"id": "sea", "keywords": ["sø"]}])
assert not result.matched
def test_low_fuzzy_match_requests_clarification():
result = match_intent("skåven", INTENTS)
assert not result.matched
assert result.response_type == "clarify"
def test_conflicting_intents_are_not_guessed():
result = match_intent("skoven og haven", INTENTS)
assert not result.matched
assert result.response_type == "clarify"
def test_text_endpoint_can_progress_the_current_scene():
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={
"scene_id": "0",
"question_id": started["voice"]["question"]["id"],
"text": "Ja, jeg er klar!",
},
)
assert response.status_code == 200
data = response.get_json()["data"]
assert data["matched"]
assert data["intent"] == "ready"
assert data["next_action"]["choice_id"] == "roll_scene"
assert len(data["gameState"]["history"]) == 1
def test_spoken_option_number_selects_one_of_the_four_buttons():
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
choice_scene = client.post("/api/kast").get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={
"scene_id": "1",
"question_id": choice_scene["voice"]["question"]["id"],
"text": "Jeg vælger mulighed to",
},
)
data = response.get_json()["data"]
assert data["intent"] == "choose_klog"
assert data["next_action"]["choice_id"] == "interaction:klog"
assert data["gameState"]["history"][-1]["selection"] == "Smør tandhjulene"
def test_text_endpoint_rejects_a_stale_scene():
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={"scene_id": "4", "text": "ja"},
)
assert response.status_code == 409
assert response.get_json()["error"] == "stale_scene"
def test_no_match_gets_a_fun_fallback_and_continues_the_story():
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={"scene_id": "0", "text": "en kæmpe lilla drage"},
)
assert response.status_code == 200
data = response.get_json()["data"]
assert not data["matched"]
assert "sjovt svar" in data["child_response"]
assert len(data["gameState"]["history"]) == 1
assert len(client.get("/api/scene").get_json()["history"]) == 1
@patch("app.routes.api.classify_with_hermes")
def test_hermes_answers_about_scene_and_then_continues_game(hermes):
hermes.return_value = {
"scope": "game", "action": "answer", "choice_id": None, "intent": None,
"confidence": 0.93, "reply": "Angel er Fluttershys lille hvide kanin.",
"method": "hermes",
}
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 1}).get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={"scene_id": "0", "text": "Hvem er Angel?"},
)
data = response.get_json()["data"]
assert data["matched"]
assert data["match_method"] == "hermes"
assert data["next_action"]["choice_id"] == "roll_scene"
assert len(data["gameState"]["history"]) == 1
assert len(client.get("/api/scene").get_json()["history"]) == 1
@patch("app.routes.api.classify_with_hermes")
def test_hermes_story_comment_returns_a_spoken_response_and_continues(hermes):
hermes.return_value = {
"scope": "game", "action": "answer", "choice_id": None, "intent": None,
"confidence": 0.91, "reply": "Ja, de røde æbler ser lækre og sprøde ud!",
"method": "hermes",
}
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 2}).get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={"scene_id": "0", "text": "De æbler ser lækre ud"},
)
data = response.get_json()["data"]
assert data["child_response"] == "Ja, de røde æbler ser lækre og sprøde ud!"
assert len(data["gameState"]["history"]) == 1
assert len(client.get("/api/scene").get_json()["history"]) == 1
@patch("app.routes.api.classify_with_hermes")
def test_a_comment_also_continues_a_four_option_scene(hermes):
hermes.return_value = {
"scope": "game", "action": "answer", "choice_id": None, "intent": None,
"confidence": 0.92, "reply": "Møllehjulet snurrer næsten som en fjollet karussel!",
"method": "hermes",
}
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
choice_scene = client.post("/api/kast").get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={
"scene_id": "1",
"question_id": choice_scene["voice"]["question"]["id"],
"text": "Det ligner en karussel",
},
)
data = response.get_json()["data"]
assert data["child_response"] == "Møllehjulet snurrer næsten som en fjollet karussel!"
assert data["gameState"]["interactionProgressed"]
assert len(data["gameState"]["history"]) == 2
@patch("app.routes.api.classify_with_hermes")
def test_hermes_must_not_say_the_game_waits_when_voice_always_continues(hermes):
hermes.return_value = {
"scope": "game", "action": "answer", "choice_id": None, "intent": None,
"confidence": 0.95,
"reply": "Vi kan vente, til du er klar til at gætte igen.",
"method": "hermes",
}
client = make_client()
started = client.post("/api/start", json={"type": 0, "tema": 0}).get_json()
client.post("/api/kast")
choice = client.get("/api/scene").get_json()
client.post("/api/interact", json={"selection": choice["interaction"]["options"][0]["id"]})
color = client.get("/api/scene").get_json()
response = client.post(
f"/api/v1/games/{started['gameId']}/voice/text",
json={
"scene_id": "2",
"question_id": color["voice"]["question"]["id"],
"text": "Det ved jeg ikke",
},
)
data = response.get_json()["data"]
assert "vente" not in data["child_response"]
assert "videre" in data["child_response"]
assert data["gameState"]["interactionProgressed"]