From 7ee1d4bb63c12982ec4900ec870ad96baba4b22b Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 14:49:17 -0400 Subject: feat: combine wizard menus into a single generate options menu --- app/tests/test_hub.py | 239 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 168 insertions(+), 71 deletions(-) (limited to 'app/tests/test_hub.py') diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 1b7630e..d244144 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -17,13 +17,15 @@ from ui import hub, tui class _ScriptedTUI: """Stand-in for the tui widget module: answers each menu/line_edit/ - confirm call from a scripted answer list and records every prompt.""" + confirm/form call from a scripted answer list and records every prompt.""" def __init__(self): self.script = [] self.prompts = [] self.options_seen = [] self.flashes = [] + self.form_script = [] # values dicts / None / sentinel for tui.form + self.forms_seen = [] # (title, fields, kwargs) for tui.form def _next(self, prompt, options=None): self.prompts.append(prompt) @@ -41,6 +43,10 @@ class _ScriptedTUI: def confirm(self, stdscr, question, **kwargs): return self._next(question) + def form(self, stdscr, title, fields, **kwargs): + self.forms_seen.append((title, fields, kwargs)) + return self.form_script.pop(0) + def flash(self, stdscr, text, kind="warn"): self.flashes.append(text) @@ -418,19 +424,30 @@ class SubmenuStatusTableTests(unittest.TestCase): class ConvertFlowTests(unittest.TestCase): - """_convert_audiocpp / _convert_faster: local-config menus vs. live - queries against a running remote server.""" + """_convert_audiocpp / _convert_qwen / _convert_faster: each backend + collects its settings on a single form (local config or live remote + queries).""" def setUp(self): self.tui = _ScriptedTUI() - for name in ("menu", "line_edit", "confirm", "flash"): + for name in ("menu", "line_edit", "confirm", "form", "flash"): patcher = patch.object(hub.tui, name, getattr(self.tui, name)) patcher.start() self.addCleanup(patcher.stop) - def _answer_common_options(self): - # Output format, speed, single-file, debug. - self.tui.script += ["m4b", "1.5", False, False] + def _form_values(self, **overrides): + """A fully-populated form result, with sensible defaults.""" + values = {"output_format": "m4b", "speed": "1.0", + "single_file": False, "debug": False} + values.update(overrides) + return values + + def _answer_form(self, **overrides): + self.tui.form_script.append(self._form_values(**overrides)) + + def _field(self, key, form_index=-1): + _, fields, _ = self.tui.forms_seen[form_index] + return next(f for f in fields if f["key"] == key) # ------------------------------------------------------------------ # audio.cpp: remote server (no local checkout / server.json) @@ -450,90 +467,121 @@ class ConvertFlowTests(unittest.TestCase): patcher.start() self.addCleanup(patcher.stop) - def test_audiocpp_remote_queries_live_models_and_voices(self): + def test_audiocpp_remote_builds_one_form(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["higgs", "narrator", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="higgs", voice="narrator", + instructions="", speed="1.5") + cmd = hub._convert_audiocpp(None) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) kwargs = cmd[2] self.assertEqual(kwargs["model_id"], "higgs") self.assertEqual(kwargs["voice"], "narrator") self.assertIsNone(kwargs["instructions"]) - # The model menu was fed from the live query. - self.assertEqual(self.tui.options_seen[0], + self.assertEqual(kwargs["output_format"], "m4b") + self.assertEqual(kwargs["speed"], 1.5) + self.assertFalse(kwargs["single_file"]) + self.assertFalse(kwargs["debug"]) + # One form, not a cascade of menus/editors. + self.assertEqual(len(self.tui.forms_seen), 1) + title, fields, form_kwargs = self.tui.forms_seen[0] + self.assertEqual(title, "Convert with audio.cpp") + self.assertEqual([f["key"] for f in fields], + ["model_id", "voice", "instructions", + "output_format", "speed", "single_file", "debug"]) + self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) + self.assertTrue(form_kwargs["start_on_buttons"]) + # The model menu was fed from the live query (label, id). + self.assertEqual(fields[0]["choices"], [("higgs (higgs_audio_tts, tts)", "higgs")]) - def test_audiocpp_remote_qwen3_tts_offers_builtin_speaker_first(self): + def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self): self._patch_remote( [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["qwen", None, ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="qwen", voice="(built-in speaker)", + instructions="") + cmd = hub._convert_audiocpp(None) + # The sentinel maps to "no voice" (built-in speaker). self.assertIsNone(cmd[2]["voice"]) - self.assertEqual(self.tui.options_seen[1], - [("(built-in speaker)", None), ("narrator", "narrator")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("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 menus (voice optional). + # qwen3_tts/tts and so must the form (voice optional). self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - # Empty server voices: no Voice menu, built-in speaker implied. - self.tui.script += ["legacy", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="legacy", + voice="(built-in speaker)", instructions="") + cmd = hub._convert_audiocpp(None) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) - def test_audiocpp_remote_vdes_needs_instructions_not_voice(self): + def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["design", "A warm British narrator"] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="design", voice=None, + instructions="A warm British narrator") + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") - # No voice prompt happened at all. - self.assertNotIn("Voice", [p for p in self.tui.prompts]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("voice") + self.assertFalse(voice_field["visible"](fields)) + instr = self._field("instructions") + self.assertIsNotNone(instr["validate"]("")) + self.assertIsNone(instr["validate"]("describe me")) + + def test_audiocpp_required_voice_validates(self): + # A non-qwen3_tts family needs a --voice; a blank value refuses. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(model_id="higgs", voice="narrator", + instructions="") + hub._convert_audiocpp(None) + voice_field = self._field("voice") + self.assertIsNotNone(voice_field["validate"]("")) + self.assertIsNone(voice_field["validate"]("narrator")) def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON - cmd = hub._convert_audiocpp(None, []) + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd) self.assertIn("Could not list models", self.tui.flashes[0]) def test_audiocpp_remote_empty_models_flash_and_abort(self): self._patch_remote([]) - cmd = hub._convert_audiocpp(None, []) + cmd = hub._convert_audiocpp(None) self.assertIsNone(cmd) self.assertIn("hosts no model entries", self.tui.flashes[0]) - def test_audiocpp_remote_no_server_voices_for_clone_model_aborts(self): + def test_audiocpp_remote_no_server_voices_leaves_voice_blank(self): + # 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"}], voices=[]) - self.tui.script += ["higgs"] - cmd = hub._convert_audiocpp(None, []) - self.assertIsNone(cmd) - self.assertIn("lists none", self.tui.flashes[0]) - - def test_audiocpp_remote_failed_voices_query_aborts(self): - self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], - voices=None) - self.tui.script += ["higgs"] - cmd = hub._convert_audiocpp(None, []) - self.assertIsNone(cmd) - self.assertIn("Could not list voices", self.tui.flashes[0]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(model_id="higgs", voice="", instructions="") + cmd = hub._convert_audiocpp(None) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("voice") + self.assertEqual(voice_field["choices"](fields), []) # ------------------------------------------------------------------ # audio.cpp: local managed setup keeps reading its server.json @@ -559,29 +607,87 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.audiocpp_backend, "fetch_server_models", must_not_query), \ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self.tui.script += ["qwen", "Narrator", ""] - self._answer_common_options() - cmd = hub._convert_audiocpp(None, []) + self._answer_form(model_id="qwen", voice="Narrator", + instructions="") + cmd = hub._convert_audiocpp(None) self.assertEqual(queried, []) self.assertIsNotNone(cmd) self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") + # ------------------------------------------------------------------ + # common fields: output format, speed, single-file, debug + # ------------------------------------------------------------------ + + def test_common_fields_hide_combine_for_m4b(self): + fields = hub._common_fields() + fmt = next(f for f in fields if f["key"] == "output_format") + single = next(f for f in fields if f["key"] == "single_file") + fmt["value"] = "m4b" + self.assertFalse(single["visible"](fields)) + fmt["value"] = "mp3" + self.assertTrue(single["visible"](fields)) + + # ------------------------------------------------------------------ + # qwen: speaker or clone + # ------------------------------------------------------------------ + + def test_qwen_builds_speaker_and_clone_form(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", + ["Vivian", "Serena"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(mode="custom", speaker="Serena", clone="") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = hub._convert_qwen(None) + speaker_in_memory = hub.config.SPEAKER + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], hub.BACKEND_QWEN) + self.assertIsNone(cmd[2]["clone"]) + # The speaker choice is persisted for future runs too. + mk_update.assert_called_once_with("SPEAKER", "Serena") + self.assertEqual(speaker_in_memory, "Serena") + fields = self.tui.forms_seen[0][1] + self.assertEqual([f["key"] for f in fields], + ["mode", "speaker", "clone", "output_format", + "speed", "single_file", "debug"]) + mode_field = self._field("mode") + self.assertEqual(mode_field["choices"], + [("Built-in speaker", "custom"), + ("Clone from a .wav file", "clone")]) + speaker_field = self._field("speaker") + clone_field = self._field("clone") + # Speaker shows in custom mode; the .wav path shows in clone mode. + self.assertTrue(speaker_field["visible"](fields)) + self.assertFalse(clone_field["visible"](fields)) + mode_field["value"] = "clone" + self.assertFalse(speaker_field["visible"](fields)) + self.assertTrue(clone_field["visible"](fields)) + + def test_qwen_clone_mode_passes_path_and_keeps_speaker(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(mode="clone", speaker="Vivian", + clone="/tmp/ref.wav") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = hub._convert_qwen(None) + self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") + # Clone mode does not touch the global speaker. + mk_update.assert_not_called() + # ------------------------------------------------------------------ # faster: remote server (no local voices.json) # ------------------------------------------------------------------ - def test_faster_remote_prompts_for_a_voice_name(self): + def test_faster_remote_uses_a_text_voice_field(self): with tempfile.TemporaryDirectory() as td: with patch.object(hub.faster_backend, "_checkout", return_value=Path(td)): - self.tui.script += ["obama"] - self._answer_common_options() + self._answer_form(voice="obama") cmd = hub._convert_faster(None) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], "faster") self.assertEqual(cmd[2]["voice"], "obama") - self.assertIn("Server-side voice", self.tui.prompts[0]) + self.assertEqual(self._field("voice")["kind"], "text") def test_faster_local_still_lists_voices_json(self): with tempfile.TemporaryDirectory() as td: @@ -590,12 +696,13 @@ class ConvertFlowTests(unittest.TestCase): json.dumps({"default": {}, "obama": {}}), encoding="utf-8") with patch.object(hub.faster_backend, "_checkout", return_value=checkout): - self.tui.script += ["obama"] - self._answer_common_options() + self._answer_form(voice="obama") cmd = hub._convert_faster(None) self.assertEqual(cmd[2]["voice"], "obama") - # The voice came from a menu over voices.json, not a text field. - self.assertIn("Select the voice to clone", self.tui.prompts[0]) + voice_field = self._field("voice") + self.assertEqual(voice_field["kind"], "choice") + self.assertEqual(voice_field["choices"], + [("default", "default"), ("obama", "obama")]) class SelectSpecTests(unittest.TestCase): @@ -690,15 +797,7 @@ class RunConversionTests(unittest.TestCase): class AddAutostartTests(unittest.TestCase): - """_add_autostart: offers to start the server when it isn't running.""" - - def setUp(self): - tui._THEME.clear() - self.curses = FakeCurses() - self._patcher = patch.dict("sys.modules", {"curses": self.curses}) - self._patcher.start() - self.addCleanup(self._patcher.stop) - self.addCleanup(tui._THEME.clear) + """_add_autostart: always starts the server when it isn't running.""" def _status(self): spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) @@ -706,20 +805,18 @@ class AddAutostartTests(unittest.TestCase): configured=True, running=False, servers=[spec]) - def test_sets_autostart_when_user_confirms(self): - screen = FakeScreen(keys=[10]) # Enter = Yes + def test_sets_autostart_when_not_running(self): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=False): - hub._add_autostart(screen, cmd, [self._status()]) + hub._add_autostart(cmd, [self._status()]) self.assertEqual(cmd[2]["autostart"], "qwen-custom") def test_no_autostart_when_server_already_running(self): - screen = FakeScreen(keys=[10]) cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=True): - hub._add_autostart(screen, cmd, [self._status()]) + hub._add_autostart(cmd, [self._status()]) self.assertNotIn("autostart", cmd[2]) -- cgit v1.2.3