reachy_conv_app / tests /test_settings_put_handler.py
ArtFix0's picture
v0.5.24: chatterbox engine save 500 + cloned voices actually used end-to-end
abe9e54 verified
Raw
History Blame Contribute Delete
10.5 kB
"""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