Spaces:
Running
Running
| """Tests for the PUT /api/settings handler with the flat | |
| chatterbox / qwentts save shape. | |
| v0.5.24 (Fix B1): the dashboard's `gatherSettingsPatch()` now | |
| sends the chatterbox + qwentts knobs as flat siblings of | |
| `engine` / `voice` (matching the backend's `TTSSettings` | |
| schema) instead of nested under `tts.chatterbox: {...}` and | |
| `tts.qwentts: {...}` sub-dicts. The previous nested shape | |
| was silently dropped by Pydantic with default | |
| `extra="ignore"` — the user's chatterbox_ref_audio | |
| selection (and all the other chatterbox + qwentts knobs) | |
| were never persisted. | |
| These tests pin the wire contract: a flat payload is | |
| accepted and round-trips through `Settings.model_dump` | |
| with the chatterbox_*/qwentts_* fields intact, while a | |
| nested payload (the old, broken shape) is also accepted | |
| (by `Settings.model_validate`, which ignores the | |
| unknown nested keys) but DOES NOT populate the flat | |
| fields. The dashboard only sends flat; the nested-shape | |
| test guards against the saved-on-disk config from an | |
| older build causing a regression. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| import pytest | |
| _tmp_config = tempfile.mkdtemp(prefix="reachy-settings-test-") | |
| os.environ["REACHY_CONV_APP_CONFIG"] = os.path.join(_tmp_config, "config.yaml") | |
| # Make `src/reachy_conv_app` importable. | |
| _REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) | |
| _SRC = os.path.join(_REPO, "src") | |
| if _SRC not in sys.path: | |
| sys.path.insert(0, _SRC) | |
| from reachy_conv_app.dashboard import server as dash_server # noqa: E402 | |
| from reachy_conv_app.local_settings import ( # noqa: E402 | |
| Settings, | |
| TTSSettings, | |
| load_settings, | |
| save_settings, | |
| ) | |
| def _isolated_config(tmp_path, monkeypatch): | |
| """Point the settings file at a tmp dir for each test. | |
| Returns the Path to the config.yaml the test should | |
| write to (autouse; the test only needs to use it if it | |
| wants to pre-populate the file).""" | |
| p = tmp_path / "config.yaml" | |
| monkeypatch.setenv("REACHY_CONV_APP_CONFIG", str(p)) | |
| return p | |
| def client(): | |
| """A FastAPI TestClient with the app_state reset.""" | |
| from fastapi.testclient import TestClient | |
| saved_handler = dash_server.app_state.handler | |
| dash_server.app_state.handler = None | |
| app = dash_server.create_app() | |
| with TestClient(app) as c: | |
| yield c | |
| dash_server.app_state.handler = saved_handler | |
| # --- Fix B1: flat chatterbox payload round-trips -------------------------- | |
| def test_put_settings_accepts_flat_chatterbox_payload(client): | |
| """The dashboard now sends the chatterbox knobs as flat | |
| siblings of `engine`. The PUT /api/settings handler must | |
| accept the flat shape and persist chatterbox_ref_audio | |
| to disk so the user's cloned-voice selection survives a | |
| page reload. | |
| Bug this guards against: the old nested payload | |
| `tts.chatterbox: {ref_audio: "..."}` was silently | |
| dropped by Pydantic with default extra="ignore", so | |
| the saved config had chatterbox_ref_audio="" no | |
| matter what the user selected in the dashboard. | |
| """ | |
| payload = { | |
| "tts": { | |
| "engine": "chatterbox", | |
| "voice": "en-US-AriaNeural", | |
| "chatterbox_model": "chatterbox-turbo", | |
| "chatterbox_device": "cpu", | |
| "chatterbox_exaggeration": 0.7, | |
| "chatterbox_cfg_weight": 0.5, | |
| "chatterbox_ref_audio": "/home/user/.cache/voices/my-voice/source.wav", | |
| }, | |
| } | |
| r = client.put("/api/settings", json=payload) | |
| assert r.status_code == 200, r.text | |
| body = r.json() | |
| assert body["tts"]["engine"] == "chatterbox" | |
| # The flat chatterbox_ref_audio field MUST be persisted. | |
| # (This was the bug — previously the nested sub-tree was | |
| # silently dropped.) | |
| assert body["tts"]["chatterbox_ref_audio"] == ( | |
| "/home/user/.cache/voices/my-voice/source.wav" | |
| ) | |
| assert body["tts"]["chatterbox_model"] == "chatterbox-turbo" | |
| assert body["tts"]["chatterbox_exaggeration"] == 0.7 | |
| def test_put_settings_persists_flat_chatterbox_to_disk(client, _isolated_config): | |
| """Beyond the response, the flat chatterbox_ref_audio | |
| must actually be written to the on-disk config.yaml so | |
| it survives a restart. This is what the user's | |
| 'pick a cloned voice, save, restart, voice is still | |
| picked' flow depends on.""" | |
| payload = { | |
| "tts": { | |
| "engine": "chatterbox", | |
| "chatterbox_ref_audio": "/home/user/.cache/voices/my-voice/source.wav", | |
| }, | |
| } | |
| r = client.put("/api/settings", json=payload) | |
| assert r.status_code == 200 | |
| # Reload from disk to confirm the file was actually written | |
| # with the flat field (not dropped on save). | |
| s = load_settings() | |
| assert s.tts.engine == "chatterbox" | |
| assert s.tts.chatterbox_ref_audio == ( | |
| "/home/user/.cache/voices/my-voice/source.wav" | |
| ) | |
| # --- The OLD nested shape is dropped (regression guard) ------------------- | |
| def test_put_settings_nested_chatterbox_subtree_is_dropped(client, _isolated_config): | |
| """v0.5.24 changed the dashboard to flat. But a config.yaml | |
| written by an older build would still have the nested | |
| `chatterbox: {ref_audio: "..."}` sub-tree at the tts | |
| level. Pydantic with default extra="ignore" drops the | |
| nested sub-tree on Settings.model_validate — the | |
| flat chatterbox_ref_audio field stays at its default | |
| (empty string). | |
| This test pins that behaviour so a future refactor of | |
| the Settings model doesn't accidentally start honouring | |
| the old nested shape (which would be a real wire- | |
| format change and would need its own migration). | |
| """ | |
| # Write a config file that mimics the OLD nested shape | |
| # directly to disk, then PUT a partial update on top. | |
| # The Settings.model_validate step inside the PUT | |
| # handler must drop the nested chatterbox sub-tree. | |
| import yaml | |
| raw = { | |
| "tts": { | |
| "engine": "chatterbox", | |
| "chatterbox": { # OLD nested shape | |
| "ref_audio": "/path/from/old/nested/voice.wav", | |
| "model": "chatterbox-multilingual", | |
| }, | |
| }, | |
| } | |
| _isolated_config.write_text(yaml.safe_dump(raw), encoding="utf-8") | |
| # Now PUT a partial update with a flat chatterbox_ref_audio. | |
| r = client.put("/api/settings", json={ | |
| "tts": {"chatterbox_ref_audio": "/new/flat/value.wav"}, | |
| }) | |
| assert r.status_code == 200 | |
| body = r.json() | |
| # The flat field was set; the old nested chatterbox sub-tree | |
| # is dropped by Pydantic, so the OTHER chatterbox_* fields | |
| # are at their TTSSettings defaults (not the old nested values). | |
| assert body["tts"]["chatterbox_ref_audio"] == "/new/flat/value.wav" | |
| assert body["tts"]["chatterbox_model"] == "chatterbox-turbo" # default, not old nested | |
| # The nested key itself shouldn't be in the response | |
| # (model_dump doesn't emit it; it was ignored on validate). | |
| assert "chatterbox" not in body["tts"] | |
| # --- Flat qwentts payload also round-trips -------------------------------- | |
| def test_put_settings_accepts_flat_qwentts_payload(client): | |
| """Same shape mismatch existed for qwentts. The dashboard | |
| now sends flat qwentts_* fields. The handler must accept | |
| them and persist. | |
| qwentts is dormant in this build (see tts/manager.py:208-227 | |
| and tts/__init__.py:14) — the runtime doesn't read these | |
| fields — but the wire shape still has to match the on-disk | |
| schema for consistency and for the day qwentts is | |
| re-enabled. | |
| """ | |
| payload = { | |
| "tts": { | |
| "engine": "edge", # any non-qwentts; the qwentts_* fields are still persisted | |
| "qwentts_binary_path": "", | |
| "qwentts_model_size": "0.6B", | |
| "qwentts_language": "english", | |
| "qwentts_voice_mode": "preset", | |
| "qwentts_voice_style": "speak like a teenage girl", | |
| "qwentts_advanced": { | |
| "seed": 42, | |
| "max_new_tokens": 1024, | |
| "top_k": 50, | |
| "temperature": 0.9, | |
| "top_p": 1.0, | |
| "repetition_penalty": 1.05, | |
| "do_sample": True, | |
| }, | |
| }, | |
| } | |
| r = client.put("/api/settings", json=payload) | |
| assert r.status_code == 200, r.text | |
| body = r.json() | |
| # The flat qwentts_* fields are persisted. | |
| assert body["tts"]["qwentts_model_size"] == "0.6B" | |
| assert body["tts"]["qwentts_voice_mode"] == "preset" | |
| assert body["tts"]["qwentts_voice_style"] == "speak like a teenage girl" | |
| # The advanced sub-dict (AdvancedQwenttsSettings) IS a | |
| # sub-model on the backend, so it round-trips as a dict. | |
| assert body["tts"]["qwentts_advanced"]["seed"] == 42 | |
| assert body["tts"]["qwentts_advanced"]["temperature"] == 0.9 | |
| # --- Field validation: invalid chatterbox_model is rejected ------------- | |
| def test_put_settings_rejects_invalid_chatterbox_model(client): | |
| """chatterbox_model is a Literal["chatterbox", "chatterbox-turbo", | |
| "chatterbox-multilingual"]; an unknown value is a 400.""" | |
| payload = { | |
| "tts": { | |
| "chatterbox_model": "chatterbox-fake", # not a known variant | |
| }, | |
| } | |
| r = client.put("/api/settings", json=payload) | |
| assert r.status_code == 400 | |
| assert "chatterbox_model" in r.json()["detail"] or "chatterbox" in r.json()["detail"] | |
| # --- Defaults are unchanged when the chatterbox sub-tree is absent ------- | |
| def test_put_settings_preserves_chatterbox_defaults_when_not_sent(client): | |
| """When the dashboard sends a partial patch that doesn't | |
| include any chatterbox_* fields, the existing values | |
| are preserved (the handler deep-merges). This is the | |
| 'edit voice, save, chatterbox settings intact' flow.""" | |
| # Set up: pre-populate with non-default chatterbox values. | |
| s = load_settings() | |
| s.tts.chatterbox_exaggeration = 1.2 | |
| s.tts.chatterbox_ref_audio = "/pre/existing/voice.wav" | |
| save_settings(s) | |
| # Edit: send a patch that only changes the LLM timeout. | |
| payload = { | |
| "llm": {"timeout_s": 123.0}, | |
| } | |
| r = client.put("/api/settings", json=payload) | |
| assert r.status_code == 200 | |
| body = r.json() | |
| # The chatterbox fields are unchanged. | |
| assert body["tts"]["chatterbox_exaggeration"] == 1.2 | |
| assert body["tts"]["chatterbox_ref_audio"] == "/pre/existing/voice.wav" | |
| # And the LLM change took effect. | |
| assert body["llm"]["timeout_s"] == 123.0 | |