- 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
220 lines
8.0 KiB
Python
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"]
|