From 867866f131b0b6c76c54272791e7f7dea01db990 Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 25 Aug 2026 16:40:30 -0400 Subject: feat: better tui menu option gating for models that support custom voices (qwen) and models that do not support instructions --- app/tests/test_converter.py | 14 +++- app/tests/test_hub.py | 95 ++++++++++++++++++------ app/tests/test_tts.py | 177 +++++++++++++++++++++++++++++++++++++------- app/tests/test_tui.py | 11 +++ 4 files changed, 250 insertions(+), 47 deletions(-) (limited to 'app/tests') diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 9b0ddb4..317e772 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -131,6 +131,7 @@ class NarratorTagTests(unittest.TestCase): converter.voice_clone_ref_audio = ref_audio converter.backend = tts.BACKEND_QWEN converter.voice = None + converter.speaker = None converter.instructions = instructions return converter @@ -159,11 +160,12 @@ class NarratorTagTests(unittest.TestCase): self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(), "narrator") - def _audiocpp_converter(self, voice=None, instructions=None): + def _audiocpp_converter(self, voice=None, instructions=None, speaker=None): converter = self._converter(tts.VOICE_MODE_CUSTOM, instructions=instructions) converter.backend = tts.BACKEND_AUDIOCPP converter.voice = voice + converter.speaker = speaker return converter def test_audiocpp_design_run_uses_designed_tag(self): @@ -181,6 +183,15 @@ class NarratorTagTests(unittest.TestCase): converter = self._audiocpp_converter() self.assertEqual(converter._narrator_tag(), "Vivian") + def test_audiocpp_explicit_speaker_uses_speaker_tag(self): + # A chosen CustomVoice speaker names the output, not config.SPEAKER. + converter = self._audiocpp_converter(speaker="Ryan") + self.assertEqual(converter._narrator_tag(), "Ryan") + + def test_audiocpp_explicit_speaker_normalizes_display_name(self): + converter = self._audiocpp_converter(speaker="Uncle_Fu") + self.assertEqual(converter._narrator_tag(), "Uncle_Fu") + def test_preflight_design_run_uses_designed_tag(self): with tempfile.TemporaryDirectory() as books_tmp, \ tempfile.TemporaryDirectory() as output_tmp: @@ -547,6 +558,7 @@ class RunOverwritePromptTests(unittest.TestCase): self.converter.voice_clone_ref_audio = None self.converter.backend = tts.BACKEND_QWEN self.converter.voice = None + self.converter.speaker = None self.converter.instructions = None self.converter.speed = 1.0 self.converter.single_file = False diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 9842148..c5ca346 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -750,40 +750,75 @@ class ConvertFlowTests(unittest.TestCase): [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id). self.assertEqual(self._field("model_id")["choices"], - [("higgs (higgs_audio_tts, tts)", "higgs")]) + [("higgs (higgs_audio_tts, clone)", "higgs")]) - def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self): + def test_audiocpp_customvoice_entry_lists_builtin_speakers(self): + # A CustomVoice entry populates the Voice menu with the Qwen3-TTS + # built-in speakers and maps the pick to --speaker. self._patch_remote( - [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], - voices=["narrator"]) + [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "family": "qwen3_tts", "task": "tts"}]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="qwen", - audiocpp_voice="(built-in speaker)", - instructions="") + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + audiocpp_voice="Ryan", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) - # The sentinel maps to "no voice" (built-in speaker). + # The speaker is passed as --speaker, not --voice. self.assertIsNone(cmd[2]["voice"]) + self.assertEqual(cmd[2]["speaker"], "Ryan") fields = self.tui.forms_seen[0][1] voice_field = self._field("audiocpp_voice") - choices = voice_field["choices"](fields) - self.assertEqual(choices, - [("(built-in speaker)", "(built-in speaker)"), - ("narrator", "narrator")]) - - def test_audiocpp_remote_missing_family_treated_as_qwen3_tts(self): - # Legacy servers omit family/task; the converter defaults them to - # qwen3_tts/tts and so must the form (voice optional). + self.assertEqual(voice_field["choices"](fields), + [(s, s) for s in hub.QWEN3_TTS_SPEAKERS]) + # CustomVoice reads a style instruction, so the field stays visible. + instr = self._field("instructions") + self.assertTrue(instr["visible"](fields)) + self.assertIsNone(instr["validate"]("")) + + def test_audiocpp_qwen3_tts_base_entry_lists_clone_voices(self): + # A Base entry populates the Voice menu with the server's clone + # voices only (no built-in speakers) and maps the pick to --voice. + self._patch_remote( + [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(cmd[2]["voice"], "narrator") + self.assertIsNone(cmd[2]["speaker"]) + self.assertIsNone(cmd[2]["instructions"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertEqual(voice_field["choices"](fields), + [("narrator", "narrator")]) + # Base (clone) ignores instructions, so the field is hidden. + instr = self._field("instructions") + self.assertFalse(instr["visible"](fields)) + + def test_audiocpp_remote_missing_family_is_clone_capable(self): + # A missing family is unknown — not guessed as qwen3_tts — so the + # entry is clone-only: it needs a --voice rather than offering a + # built-in speaker. self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp-remote", model_id="legacy", - audiocpp_voice="(built-in speaker)", - instructions="") + audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) + self.assertIsNone(cmd[2]["speaker"]) + voice_field = self._field("audiocpp_voice") + # Clone-only: an empty voice is refused (no built-in speaker option). + self.assertIsNotNone(voice_field["validate"]("")) def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( @@ -800,9 +835,24 @@ class ConvertFlowTests(unittest.TestCase): voice_field = self._field("audiocpp_voice") self.assertFalse(voice_field["visible"](fields)) instr = self._field("instructions") + self.assertTrue(instr["visible"](fields)) self.assertIsNotNone(instr["validate"]("")) self.assertIsNone(instr["validate"]("describe me")) + def test_audiocpp_clone_drops_stale_instructions(self): + # A Base/clone entry ignores instructions: even if the form held a + # leftover value, the mapper must not send it to the model. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", + instructions="stale description") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNone(cmd[2]["instructions"]) + def test_audiocpp_required_voice_validates(self): # A non-qwen3_tts family needs a --voice; a blank value refuses. self._patch_remote( @@ -1082,9 +1132,11 @@ class ConvertFlowTests(unittest.TestCase): "mode", "speaker", "clone", "output_format", "speed", "single_file", "debug"]) # The form opens on the configured default (audio.cpp): its fields - # show, the other backend's hide. - for key in ("model_id", "audiocpp_voice", "instructions"): + # show, the other backend's hide. (Instructions is hidden too: the + # default higgs entry is clone-only, which ignores instructions.) + for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("instructions")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. @@ -1100,8 +1152,9 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(self._field(key)["visible"](fields)) # And back to audio.cpp. fields[0]["value"] = "audiocpp" - for key in ("model_id", "audiocpp_voice", "instructions"): + for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("instructions")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index b43919d..0e35f78 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -479,8 +479,10 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): def setUp(self): # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests - # exercise a configured single-model server, so pin a concrete id. - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") + # exercise a configured single-model CustomVoice server, so pin a + # concrete id whose "customvoice" substring marks it speaker-capable. + patcher = patch.object( + config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") patcher.start() self.addCleanup(patcher.stop) @@ -500,7 +502,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): else {"status": "ok"}) if url.endswith("/v1/models"): return self._json_response(models if models is not None else - {"data": [{"id": config.AUDIOCPP_MODEL_ID}]}) + {"data": [{"id": config.AUDIOCPP_MODEL_ID, + "family": "qwen3_tts"}]}) if "/v1/audio/voices" in url: if voices is Exception: raise Exception("voices endpoint down") @@ -509,11 +512,12 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): raise AssertionError(f"unexpected URL: {url}") return _dispatch - def _client(self, voice=None, language=None, model_id=None, **kwargs): + def _client(self, voice=None, language=None, model_id=None, speaker=None, + **kwargs): with patch("converter.tts.urllib.request.urlopen", side_effect=self._get_responses(**kwargs)): return AudioCppTTSClient(voice=voice, language=language, - model_id=model_id) + model_id=model_id, speaker=speaker) def test_unreachable_server_raises_with_readme_pointer(self): import urllib.error @@ -551,6 +555,42 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): client = self._client() self.assertEqual(client.voice, "Uncle Fu") + def test_explicit_speaker_selects_speaker_mode(self): + # --speaker picks a CustomVoice speaker; the name is normalized to + # its wire (display) form and no preset validation runs. + client = self._client(speaker="Uncle_Fu") + self.assertEqual(client.voice, "Uncle Fu") + self.assertFalse(client.preset_mode) + + def test_explicit_speaker_on_clone_entry_raises(self): + # --speaker is meaningless on a clone-only (Base) entry. + with self.assertRaises(RuntimeError) as ctx: + self._client(speaker="Ryan", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + message = str(ctx.exception) + self.assertIn("no built-in speakers", message) + self.assertIn("--voice", message) + + def test_voice_and_speaker_are_mutually_exclusive(self): + with self.assertRaises(ValueError) as ctx: + AudioCppTTSClient(voice="narrator", speaker="Ryan") + self.assertIn("mutually exclusive", str(ctx.exception)) + + def test_no_voice_on_base_entry_raises_instead_of_silent_speaker(self): + # The Base model has no built-in speakers: without --voice the run + # fails fast instead of silently sending a speaker name that the + # model ignores. + with self.assertRaises(RuntimeError) as ctx: + self._client(model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + message = str(ctx.exception) + self.assertIn("Base-GGUF", message) + self.assertIn("--voice", message) + def test_preset_mode_uses_requested_voice(self): client = self._client(voice="narrator") self.assertEqual(client.voice, "narrator") @@ -598,7 +638,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertLogs("converter.tts", level="WARNING") as logs: client = self._client( voice="narrator", - models={"data": [{"id": "qwen3-tts"}, {"id": "pocket-tts"}]}) + models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"}, + {"id": "pocket-tts"}]}) self.assertEqual(client.model_id, "qwen3-tts") self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) @@ -632,11 +673,17 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertEqual(client.model_id, "higgs") def test_clone_model_id_ignored_for_speaker_mode(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ + # Speaker mode (no --voice on a CustomVoice entry) never reroutes to + # AUDIOCPP_CLONE_MODEL_ID — that reroute is a preset-mode concern. + with patch.object(config, "AUDIOCPP_MODEL_ID", + "Qwen3-TTS-CustomVoice"), \ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): client = self._client( - models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) - self.assertEqual(client.model_id, "qwen3-tts") + models={"data": [{"id": "Qwen3-TTS-CustomVoice", + "family": "qwen3_tts"}, + {"id": "qwen3-tts-clone", + "family": "qwen3_tts"}]}) + self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") def test_clone_model_id_equal_to_primary_is_noop(self): with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", @@ -662,9 +709,12 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("--voice", message) def test_preset_mode_with_no_matching_model_lists_both_ids(self): + # Neither the primary nor the clone id is on the server, so the + # family is unknown and no degradation warning is logged — the + # requirement error lists both configured ids instead. with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.tts", level="WARNING"): + self.assertNoLogs("converter.tts", level="WARNING"): with self.assertRaises(RuntimeError) as ctx: self._client(voice="narrator", models={"data": [{"id": "pocket-tts"}]}) @@ -678,7 +728,10 @@ class AudioCppTaskDetectionTests(unittest.TestCase): """Task auto-detection (tts/clon/vdes) and voice design validation.""" def setUp(self): - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") + # Pin a CustomVoice id so the default (no-voice) path is speaker + # mode; individual tests override family/task to exercise other paths. + patcher = patch.object( + config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") patcher.start() self.addCleanup(patcher.stop) @@ -853,18 +906,19 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertEqual(client.family, "higgs_audio_tts") self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) - def test_missing_family_falls_back_to_qwen3_tts(self): + def test_missing_family_uses_generic_profile(self): + # A missing family is unknown (not guessed as qwen3_tts): it falls + # through to the generic clone-only profile. client = self._client(models={"data": [ {"id": config.AUDIOCPP_MODEL_ID}]}) - self.assertEqual(client.family, "qwen3_tts") - self.assertTrue(client.profile.builtin_speakers) + self.assertEqual(client.family, "") + self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) def test_unknown_family_uses_generic_profile(self): client = self._client(models={"data": [ {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) self.assertEqual(client.family, "future_tts") self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) - self.assertFalse(client.profile.builtin_speakers) self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT) def test_speaker_mode_rejected_for_clone_only_family(self): @@ -879,11 +933,31 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIn("no built-in speakers", message) self.assertIsNone(client) - def test_speaker_mode_allowed_for_qwen_family(self): - client = self._client(voice=None, models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + def test_speaker_mode_allowed_for_customvoice_entry(self): + # A Qwen3-TTS entry whose id names CustomVoice is speaker-capable; + # no --voice is needed. + with patch.object(config, "AUDIOCPP_MODEL_ID", + "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"): + client = self._client(voice=None, models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "family": "qwen3_tts"}]}) self.assertEqual(client.family, "qwen3_tts") + def test_speaker_mode_rejected_for_qwen_base_entry(self): + # A Qwen3-TTS entry whose id names Base (not CustomVoice) is + # clone-only, even though its family has built-in speakers on other + # entries: without --voice it fails fast. + client = None + try: + client = self._client(voice=None, models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + except RuntimeError as exc: + message = str(exc) + self.assertIn("Base-GGUF", message) + self.assertIn("--voice", message) + self.assertIsNone(client) + def test_clone_model_id_of_different_family_is_ignored(self): with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ @@ -919,6 +993,52 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto")) +class AudiocppEntryVoiceCapabilityTests(unittest.TestCase): + """The per-entry voice capability resolver (speaker/clone/design).""" + + def _cap(self, family="", task="tts", model_id=""): + return tts.audiocpp_entry_voice_capability(family, task, model_id) + + def test_vdes_task_is_design(self): + self.assertEqual(self._cap("qwen3_tts", "vdes", + "Qwen3-TTS-VoiceDesign-GGUF"), + tts.AUDIOCPP_VOICE_DESIGN) + + def test_qwen_customvoice_entry_is_speaker(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"), + tts.AUDIOCPP_VOICE_SPEAKER) + + def test_qwen_base_entry_is_clone(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-Base-GGUF"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_qwen_unidentified_entry_is_clone(self): + self.assertEqual(self._cap("qwen3_tts", "tts", "qwen"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_other_families_are_clone(self): + self.assertEqual(self._cap("higgs_audio_tts", "tts", "higgs"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_missing_family_is_clone(self): + self.assertEqual(self._cap("", "tts", "legacy"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_customvoice_match_is_case_insensitive(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-CUSTOMVOICE-GGUF"), + tts.AUDIOCPP_VOICE_SPEAKER) + + def test_customvoice_id_in_other_family_is_not_speaker(self): + # The "customvoice" substring only marks a speaker for the qwen3_tts + # family; another family with a lookalike id stays clone-only. + self.assertEqual(self._cap("future_tts", "tts", + "Qwen3-TTS-CustomVoice"), + tts.AUDIOCPP_VOICE_CLONE) + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -943,6 +1063,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.model_id = config.AUDIOCPP_MODEL_ID client.preset_mode = preset_mode client.voice = voice + client.speaker = None client.language = language client._seed = seed client.family = family @@ -953,10 +1074,12 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.request_options = dict(request_options or {}) client.design_mode = task == tts.AUDIOCPP_TASK_VDES # Mirrors the connect-time rule: an instruction-defined voice on a - # family without built-in speakers (design mode takes precedence). + # clone-capable entry with no --voice (design mode takes precedence). + capability = tts.audiocpp_entry_voice_capability( + family, task, client.model_id) client.instruction_voice = ( not preset_mode and not client.design_mode - and not client.profile.builtin_speakers + and capability == tts.AUDIOCPP_VOICE_CLONE and bool(client.instructions)) return client @@ -1396,6 +1519,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.design_mode = False client.instruction_voice = False client.instructions = "" + client.speaker = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -1425,6 +1549,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.design_mode = False client.instruction_voice = False client.instructions = "" + client.speaker = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -1466,7 +1591,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None) + api_url=None, + speaker=None) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -1478,7 +1604,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None) + api_url=None, + speaker=None) def test_audiocpp_backend_model_id_is_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1488,7 +1615,7 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with( voice="narrator", language=config.LANGUAGE, model_id="higgs", instructions=None, - request_options={}, api_url=None) + request_options={}, api_url=None, speaker=None) def test_audiocpp_backend_instructions_and_options_are_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1502,7 +1629,7 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions="A warm adult narrator", request_options={"emotion": "neutral", "speed": "1.1"}, - api_url=None) + api_url=None, speaker=None) def test_qwen_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -1529,7 +1656,7 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with( voice="narrator", language=config.LANGUAGE, model_id=None, instructions=None, request_options={}, - api_url="http://10.0.0.5:8080") + api_url="http://10.0.0.5:8080", speaker=None) with patch("converter.converter.FasterTTSClient") as mock_faster: AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, backend=tts.BACKEND_FASTER, voice="narrator", diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 964f983..543194e 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -204,6 +204,17 @@ class MenuTests(TuiTestCase): self.assertGreater(help_x, margin) self.assert_inside_border(screen) + def test_blank_line_below_the_title(self): + # A titled dialog reserves a blank line between the title row + # and the first body row. + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Pick", self.OPTIONS) + title_y = next(y for y, _, text, _ in screen.strings + if text == " Pick ") + first_y = next(y for y, _, text, _ in screen.strings + if text == "first option") + self.assertEqual(first_y, title_y + 2) + def test_up_wraps_around_to_last_option(self): screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10]) value = tui.menu(screen, "Pick one", self.OPTIONS) -- cgit v1.2.3