"""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, ) @pytest.fixture(autouse=True) 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 @pytest.fixture 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