From 4a4cbe643d42374bb557674f40b453004582370a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 12:30:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Provider=20&=20Sprache=20?= =?UTF-8?q?=E2=80=94=20Eigener-Wert-Eingabe=20+=20Tests=20(v14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.js: default_stt/llm/tts_provider und default_language von 'select' auf 'combo' umgestellt, damit neben den vordefinierten Optionen jederzeit ein beliebiger eigener Wert eingegeben werden kann (Zukunftssicherheit). combo-Renderer unterstützt jetzt auch meta.labels (Sprachkürzel → Klarname). tests/test_admin_settings.py: 28 Tests für GET/PUT/DELETE /api/admin/config — Auth, bekannte Werte, Custom-Values, Persistenz, Runtime-Wirkung. Co-Authored-By: Claude Sonnet 4.6 --- .../Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md | 4 +- app/web/app.js | 11 +- app/web/index.html | 2 +- tests/test_admin_settings.py | 191 ++++++++++++++++++ 4 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 tests/test_admin_settings.py diff --git a/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md index 1bf7932..134f411 100644 --- a/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md +++ b/Docs/Neues_Konzept_mit_TTS_auf_Mobil_Geraet.md @@ -1,6 +1,4 @@ - - -# Voice Assistant, Ubuntu 24.04, Python, iPhone, Andoid-Handsy: +# Voice Assistant, Ubuntu 24.04, Python, iPhone, Android-Handy: Ich habe mit Python unter Linux einen Voice Assistenten Programmiert, mit dem sich der User unterhalten kann. Das System soll für Senioren stundenlangen Smalltalk mit KI ermöglichen (gegen Einsamkeit, multiuserfähig, mit Gedächtnis). Es funktioniert vereinfacht so: Das System wandelt Sprache in Text um (STT), gibt den Text an ein LLM-KI-Tool weiter. Das LLM formuliert eine adäquate Text-Antwort. Die wird angezeigt und in Sprache umgewandelt (TTS) und abgespielt. diff --git a/app/web/app.js b/app/web/app.js index 0c0d6b5..b7d6d87 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -976,10 +976,10 @@ $("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = " // Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ const FIELD_META = { - default_stt_provider: { ui: "select", opts: ["openrouter","faster-whisper"], test: "stt" }, - default_llm_provider: { ui: "select", opts: ["openrouter","local-openai-compatible"], test: "llm" }, - default_tts_provider: { ui: "select", opts: ["openrouter","piper","chatterbox"], test: "tts" }, - default_language: { ui: "select", opts: ["de","en","fr","es","it","nl"], + default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" }, + default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" }, + default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" }, + default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl"], labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } }, openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" }, openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts" }, @@ -1050,8 +1050,9 @@ function _buildControl(item, meta) { if (meta.ui === "combo") { const inList = meta.opts.includes(val); + const labels = meta.labels || {}; const options = meta.opts.map((o) => - `` + `` ).join(""); const customSel = !inList ? 'selected' : ''; return ` diff --git a/app/web/index.html b/app/web/index.html index c41531c..a2b4da4 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -218,6 +218,6 @@
- + diff --git a/tests/test_admin_settings.py b/tests/test_admin_settings.py new file mode 100644 index 0000000..feefcf8 --- /dev/null +++ b/tests/test_admin_settings.py @@ -0,0 +1,191 @@ +"""Tests für den Admin-Einstellungen-Tab (Provider & Sprache + Laufzeit-Config). + +Getestet: GET /api/admin/config, PUT /api/admin/config/{key}, + DELETE /api/admin/config/{key} — Auth, Custom-Values, Persistenz. +""" + +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings +from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache + +client = TestClient(app) +ADMIN = "test-admin-key" +ADM_HDR = {"X-Admin-Key": ADMIN} + +PROVIDER_KEYS = [ + "default_stt_provider", + "default_llm_provider", + "default_tts_provider", + "default_language", +] + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + monkeypatch.setattr(settings, "auth_enabled", False) + invalidate_cache() + yield + invalidate_cache() + + +# ── GET /api/admin/config ──────────────────────────────────────────────────── + +def test_get_config_returns_all_keys(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + assert resp.status_code == 200 + keys = {e["key"] for e in resp.json()} + assert keys == set(RUNTIME_SETTABLE.keys()) + + +def test_get_config_requires_admin_key(): + resp = client.get("/api/admin/config") + assert resp.status_code == 401 + + +def test_get_config_provider_keys_present(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + keys = {e["key"] for e in resp.json()} + for k in PROVIDER_KEYS: + assert k in keys, f"Schlüssel {k!r} fehlt in /api/admin/config" + + +def test_get_config_entry_structure(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + entry = next(e for e in resp.json() if e["key"] == "default_stt_provider") + assert "label" in entry + assert "hint" in entry + assert "effective_value" in entry + assert "base_value" in entry + assert "is_overridden" in entry + assert entry["is_overridden"] is False + + +def test_get_config_not_overridden_initially(): + resp = client.get("/api/admin/config", headers=ADM_HDR) + for entry in resp.json(): + if entry["key"] in PROVIDER_KEYS: + assert not entry["is_overridden"], ( + f"{entry['key']} sollte initial nicht überschrieben sein" + ) + + +# ── PUT /api/admin/config/{key} ────────────────────────────────────────────── + +@pytest.mark.parametrize("key,value", [ + ("default_stt_provider", "openrouter"), + ("default_stt_provider", "faster-whisper"), + ("default_llm_provider", "openrouter"), + ("default_llm_provider", "local-openai-compatible"), + ("default_tts_provider", "openrouter"), + ("default_tts_provider", "piper"), + ("default_tts_provider", "chatterbox"), + ("default_language", "de"), + ("default_language", "en"), +]) +def test_put_known_value_accepted(key, value): + resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": value}) + assert resp.status_code == 200 + assert resp.json()["value"] == value + + +@pytest.mark.parametrize("key,custom_value", [ + ("default_stt_provider", "my-custom-stt"), + ("default_llm_provider", "local-openai-compatible-v2"), + ("default_tts_provider", "future-tts-engine"), + ("default_language", "pl"), +]) +def test_put_custom_value_accepted(key, custom_value): + """Eigener Wert (nicht in der Dropdown-Liste) muss akzeptiert werden.""" + resp = client.put(f"/api/admin/config/{key}", headers=ADM_HDR, json={"value": custom_value}) + assert resp.status_code == 200 + assert resp.json()["value"] == custom_value + + +def test_put_custom_value_reflected_in_get(): + """Gespeicherter Custom-Wert erscheint in effective_value.""" + client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR, + json={"value": "my-new-stt"}) + invalidate_cache() + resp = client.get("/api/admin/config", headers=ADM_HDR) + entry = next(e for e in resp.json() if e["key"] == "default_stt_provider") + assert entry["effective_value"] == "my-new-stt" + assert entry["is_overridden"] is True + + +def test_put_unknown_key_returns_400(): + resp = client.put("/api/admin/config/nonexistent_key", headers=ADM_HDR, + json={"value": "x"}) + assert resp.status_code == 400 + + +def test_put_trims_whitespace(): + resp = client.put("/api/admin/config/default_stt_provider", headers=ADM_HDR, + json={"value": " openrouter "}) + assert resp.status_code == 200 + assert resp.json()["value"] == "openrouter" + + +def test_put_requires_admin_key(): + resp = client.put("/api/admin/config/default_stt_provider", json={"value": "x"}) + assert resp.status_code == 401 + + +# ── DELETE /api/admin/config/{key} ────────────────────────────────────────── + +def test_delete_removes_override(): + client.put("/api/admin/config/default_tts_provider", headers=ADM_HDR, + json={"value": "future-engine"}) + invalidate_cache() + + del_resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR) + assert del_resp.status_code == 200 + assert del_resp.json()["deleted"] == "default_tts_provider" + + invalidate_cache() + cfg = {e["key"]: e for e in + client.get("/api/admin/config", headers=ADM_HDR).json()} + assert not cfg["default_tts_provider"]["is_overridden"] + + +def test_delete_nonexistent_override_returns_404(): + resp = client.delete("/api/admin/config/default_tts_provider", headers=ADM_HDR) + assert resp.status_code == 404 + + +def test_delete_unknown_key_returns_400(): + resp = client.delete("/api/admin/config/does_not_exist", headers=ADM_HDR) + assert resp.status_code == 400 + + +def test_delete_requires_admin_key(): + resp = client.delete("/api/admin/config/default_tts_provider") + assert resp.status_code == 401 + + +# ── Persistenz: Override wirkt sich auf Runtime aus ────────────────────────── + +def test_override_reflected_in_runtime_settings(): + from app.runtime_config import runtime_settings + client.put("/api/admin/config/default_language", headers=ADM_HDR, + json={"value": "fr"}) + invalidate_cache() + assert runtime_settings.default_language == "fr" + + +def test_delete_restores_base_value(): + from app.runtime_config import runtime_settings + base = str(settings.default_language) + + client.put("/api/admin/config/default_language", headers=ADM_HDR, + json={"value": "it"}) + invalidate_cache() + assert runtime_settings.default_language == "it" + + client.delete("/api/admin/config/default_language", headers=ADM_HDR) + invalidate_cache() + assert str(runtime_settings.default_language) == base