diff options
Diffstat (limited to 'app/tests/test_hub.py')
| -rw-r--r-- | app/tests/test_hub.py | 200 |
1 files changed, 179 insertions, 21 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index de7e4ea..6ce1943 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -13,6 +13,7 @@ from pathlib import Path from unittest.mock import patch from backends import BackendInfo, BackendStatus, ServerSpec +from converter.clients import audiocpp as audiocpp_client from tests.test_tui import FakeCurses, FakeScreen from ui import hub, tui @@ -821,6 +822,18 @@ class ConvertFlowTests(unittest.TestCase): patcher = patch.object(hub.tui, name, getattr(self.tui, name)) patcher.start() self.addCleanup(patcher.stop) + # Family voice policies are resolved from the local audio.cpp + # checkout's model_specs, which a fresh clone does not have (the + # checkout is downloaded by setup): seed the client's spec cache + # with the classifications these tests rely on, so they stay + # hermetic. Unknown families keep the clone-only default. + spec_cache = audiocpp_client._FAMILY_SPEC_TASKS + spec_cache.clear() + spec_cache.update({ + "higgs_audio_tts": {"tts", "clone"}, + "supertonic": {"tts"}, + }) + self.addCleanup(spec_cache.clear) # Keys shared by every backend entry; a "-remote" backend's other # option keys are namespaced under "<entry>." in the form dict @@ -952,9 +965,10 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id); ids are - # padded so the type column lines up across entries. + # padded so the type column lines up across entries. A mixed + # tts+clone family reads as "(tts/clone)". self.assertEqual(self._field("model_id")["choices"], - [("higgs (clone)", "higgs")]) + [("higgs (tts/clone)", "higgs")]) def test_model_menu_lines_the_type_column_up(self): # Ids are padded to the widest id: every (type) starts on the same @@ -972,7 +986,7 @@ class ConvertFlowTests(unittest.TestCase): # "a-much-longer-model-id" is 22 columns wide; both types open at # column 24 ("(" right after the two-space gutter). self.assertEqual(choices[0], - ("short".ljust(22) + " (clone)", "short")) + ("short".ljust(22) + " (tts/clone)", "short")) self.assertEqual(choices[1], ("a-much-longer-model-id (clone)", "a-much-longer-model-id")) @@ -1032,11 +1046,11 @@ class ConvertFlowTests(unittest.TestCase): self.assertTrue(instr["visible"](fields)) def test_audiocpp_model_switch_keeps_the_picked_voice(self): - # Switching models whose voice list is unchanged (two clone + # Switching models whose voice list is unchanged (two clone-only # entries sharing one server's voices) keeps the picked voice # instead of snapping back to the list's first entry. self._patch_remote( - [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "alpha", "family": "chatterbox", "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}], voices=["narrator", "second"]) self._answer_form(backend="audiocpp-remote", model_id="alpha", @@ -1090,8 +1104,8 @@ class ConvertFlowTests(unittest.TestCase): # that model's first voice (and re-points again on the way back). models = patch.object( hub.audiocpp_backend, "fetch_server_models", - lambda url: [{"id": "alpha", "family": "higgs_audio_tts", - "task": "tts"}, + lambda url: [{"id": "alpha", "family": "chatterbox", + "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}]) voices = patch.object( @@ -1117,7 +1131,7 @@ class ConvertFlowTests(unittest.TestCase): # never survives a move to a built-in-speaker entry (and vice # versa), and a design entry clears the voice again. self._patch_remote( - [{"id": "clone", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "clone", "family": "chatterbox", "task": "clon"}, {"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}, {"id": "design", "family": "qwen3_tts", "task": "vdes"}], @@ -1237,17 +1251,27 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): - # A non-qwen3_tts family needs a --voice; a blank value refuses. + # A clone-only family (Chatterbox) needs a --voice; a blank value + # refuses. A mixed tts+clone family (higgs_audio_tts) accepts the + # blank pick — it means plain TTS without a reference. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}, + {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="narrator", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field["value"] = "narrator" + model_field["value"] = "higgs" + model_field["on_change"](fields) + # Mixed family: the blank (built-in) pick is valid. + self.assertIsNone(voice_field["validate"]("")) def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self): # On a CustomVoice entry the Voice field is labelled "Built-in @@ -1281,13 +1305,13 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(label(fields), "Voice to clone") def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self): - # An Instructions text substitutes for the voice: blank Voice passes - # validation when instructions are present (instruction-voice mode), - # and is still refused without one. + # An Instructions text substitutes for the voice on clone-only + # families: blank Voice passes validation when instructions are + # present, and is still refused without one. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) @@ -1300,12 +1324,12 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(voice["validate"]("")) def test_audiocpp_no_voices_with_instructions_still_converts(self): - # A clone-capable entry whose server lists no voices is refused by + # A clone-only entry whose server lists no voices is refused by # default — but an instruction provides the voice instead. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1319,6 +1343,47 @@ class ConvertFlowTests(unittest.TestCase): instr["value"] = "designed narrator" self.assertIsNone(voice["validate"]("")) + def test_audiocpp_pure_tts_entry_hides_the_voice_menu(self): + # Pure-TTS families (spec tasks without "clone") synthesize with + # no voice at all: the Voice menu is hidden entirely, the model + # menu reads "(tts)", and Generate! sends no voice. + self._patch_remote( + [{"id": "supertonic", "family": "supertonic", "task": "tts"}]) + self._answer_form(backend="audiocpp-remote", model_id="supertonic", + audiocpp_voice=None, instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertFalse(voice_field["visible"](fields)) + self.assertEqual(self._field("model_id")["choices"], + [("supertonic (tts)", "supertonic")]) + + def test_audiocpp_mixed_family_offers_a_built_in_blank_pick(self): + # Mixed tts+clone families lead the Voice menu with a blank + # "(built-in)" pick meaning plain TTS (no reference voice), and + # the blank pick is the default. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertTrue(voice_field["visible"](fields)) + self.assertEqual(voice_field["choices"](fields), + [("", "(built-in)"), ("narrator", "narrator")]) + # A kept clone pick survives a mixed-family switch; blank is valid. + voice_field["value"] = "narrator" + self.assertIsNone(voice_field["validate"]("narrator")) + self.assertIsNone(voice_field["validate"]("")) + def test_audiocpp_request_options_map_to_kwargs(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], @@ -1478,9 +1543,9 @@ class ConvertFlowTests(unittest.TestCase): # No voices listed for a required-voice model: the form still opens # with an empty Voice field (Generate-time validation reports it). self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1520,6 +1585,34 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") + def test_audiocpp_local_rehosts_clone_only_entries(self): + # server.json written before clone-only hosting existed carries + # task "tts" for Chatterbox: opening the form re-hosts it with + # task "clon" on disk and flags the run so the autostart plan + # restarts the managed server with the corrected config. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + server_json = root / "server.json" + server_json.write_text(json.dumps({ + "models": [{"id": "Chatterbox-GGUF", + "family": "chatterbox", "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, + "find_local_checkout", return_value=root): + self._answer_form(backend="audiocpp", + model_id="Chatterbox-GGUF", + audiocpp_voice="Narrator", + instructions="") + cmd = self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertTrue(cmd[2]["audiocpp_rehost"]) + # The repair was persisted: the entry is hosted with "clon". + data = json.loads(server_json.read_text(encoding="utf-8")) + self.assertEqual(data["models"][0]["task"], "clon") + def test_managed_and_remote_both_offered(self): # A ready managed audio.cpp (server.json) AND a running remote # audio.cpp: both entries appear. The managed entry reads server.json @@ -2036,6 +2129,34 @@ class PrepareRunConfigTests(unittest.TestCase): self.assertNotIn("restart_server", kwargs) self.assertEqual(cfg.server_url, spec.url) + def test_rehost_flag_is_popped_and_reported_in_the_notice(self): + # The convert form's config repair travels as "audiocpp_rehost": + # popped from the converter kwargs and surfaced as the run notice. + spec = self._spec("audiocpp", "http://127.0.0.1:8080") + status = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, servers=[spec]) + kwargs = {"restart_server": "audiocpp", "audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch("backends.common.server_running", + return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertTrue(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertIn("restarted", cfg.notice) + + def test_rehost_notice_without_restart_when_server_was_down(self): + # The autostart path boots the fixed server.json anyway, so the + # notice only reports the re-hosting. + kwargs = {"audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[]): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertFalse(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertNotIn("restarted", cfg.notice) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): # The run-view toggle is not a converter kwarg: it moves onto the # config (and defaults to off when the form did not send it). @@ -2282,8 +2403,45 @@ class AddAutostartTests(unittest.TestCase): # external to this tool, so there is nothing to start/stop here. cmd = ("convert", "audiocpp", {"api_url": "http://10.0.0.5:8080"}) hub._add_autostart(cmd, []) + + def _audiocpp_status(self): + spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["x"]) + return BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, running=True, + servers=[spec]) + + def test_rehosted_config_restarts_the_managed_audiocpp_server(self): + # The convert form re-hosted clone-only families with task "clon" + # in server.json: the running managed server still hosts the stale + # tasks, so it is stopped and rebooted before converting. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["restart_server"], "audiocpp") self.assertNotIn("autostart", cmd[2]) + def test_rehosted_config_with_foreign_server_refuses_the_run(self): + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False): + message = hub._add_autostart(cmd, [self._audiocpp_status()]) + self.assertIsNotNone(message) + self.assertIn("stop it first", message) + self.assertNotIn("restart_server", cmd[2]) + + def test_rehosted_config_autostarts_when_server_is_down(self): + # Server not running: the plain autostart path boots it with the + # corrected server.json — no restart needed. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=False): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["autostart"], "audiocpp") + self.assertNotIn("restart_server", cmd[2]) + class SettingsTests(unittest.TestCase): """Settings menu: field collection, validation, config.py writing.""" |
