From 6ccb6d443d2fb871b43d96ea61a95bc3e6a92355 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 17:46:48 -0400 Subject: feat: combined install/configure tui screens into one menu, removed extraneous wizard screens --- app/tests/test_backends.py | 36 ++++- app/tests/test_backends_audiocpp.py | 300 ++++++++++++++++++++++++++--------- app/tests/test_backends_common.py | 3 +- app/tests/test_backends_faster.py | 161 ++++++++++++++++--- app/tests/test_converter.py | 1 - app/tests/test_converter_progress.py | 1 - app/tests/test_hub.py | 7 +- app/tests/test_taskview.py | 3 +- app/tests/test_tui.py | 52 ++++++ 9 files changed, 446 insertions(+), 118 deletions(-) (limited to 'app/tests') diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index dc2b4d0..15a8649 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -352,20 +352,28 @@ if __name__ == "__main__": class QwenSetupScreenTests(unittest.TestCase): - """qwen.setup_screen: the wizard run on the hub's screen.""" + """qwen.setup_screen: a question-free setup on the hub's screen. - def test_abort_returns_one_without_executing(self): + The qwen wizard asks nothing (ports live in Settings, the speaker is + chosen on Generate), so it cannot be aborted: an already-installed + package is a no-op flash, everything else runs in the task view. + """ + + def test_already_installed_flashes_and_skips_the_task_view(self): from backends import qwen - with patch.object(qwen, "_wizard", return_value=None) as mk_wizard, \ - patch.object(qwen, "_execute_steps") as mk_steps: + settings = {"do_install": False} + with patch.object(qwen, "_wizard", return_value=settings) as mk_wizard, \ + patch.object(qwen.tui, "flash") as mk_flash, \ + patch.object(qwen.taskview, "run_steps") as mk_run: rc = qwen.setup_screen(None) - self.assertEqual(rc, 1) + self.assertEqual(rc, 0) mk_wizard.assert_called_once() - mk_steps.assert_not_called() + mk_flash.assert_called_once() + mk_run.assert_not_called() - def test_success_runs_the_tail_in_the_task_view(self): + def test_missing_package_runs_the_tail_in_the_task_view(self): from backends import qwen - settings = {"custom_port": 7860} + settings = {"do_install": True} steps = [qwen.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(qwen, "_wizard", return_value=settings), \ patch.object(qwen, "_execute_steps", @@ -379,6 +387,18 @@ class QwenSetupScreenTests(unittest.TestCase): mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][2], steps) + def test_wizard_has_no_port_or_speaker_settings(self): + # The screens for CustomVoice/Base ports and the built-in speaker + # are gone; settings only carry whether to pip install. + from backends import qwen + args = qwen.build_parser().parse_args([]) + with patch.object(qwen, "_is_installed", return_value=False): + settings = qwen._wizard(None, args) + self.assertEqual(settings, {"do_install": True}) + with patch.object(qwen, "_is_installed", return_value=True): + settings = qwen._wizard(None, args) + self.assertEqual(settings, {"do_install": False}) + class QwenUninstallTests(unittest.TestCase): """qwen.uninstall: stop both servers, then pip-uninstall the package.""" diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 59bd039..8cd7981 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1,6 +1,5 @@ """Tests for the audio.cpp backend setup module (backends/audiocpp.py).""" -import contextlib import io import json import sys @@ -662,41 +661,105 @@ class InstallModelsTests(unittest.TestCase): self.assertEqual(run.call_count, 2) self.assertIn("exited with code 1", buf.getvalue()) - def test_decide_download_skips_prompt_without_manager(self): - self.manager.unlink() - confirm = MagicMock() - self.assertFalse(make_server.models._decide_download(self.checkout, [], confirm)) - confirm.assert_not_called() + def _entry_paths(self): + return [{"path": "models/higgs"}, {"path": "models/qwen"}] - def test_decide_download_asks_when_manager_present(self): - confirm = MagicMock(return_value=True) - self.assertTrue(make_server.models._decide_download(self.checkout, [], confirm)) - confirm.assert_called_once() + def test_installed_model_prints_no_command_for_it(self): + # Mixed selection: qwen is on disk, higgs is not. The print path + # reports the installed one without a python command, explains + # that setup downloads automatically, then lists the rest. + (self.checkout / "models" / "qwen").mkdir(parents=True) + (self.checkout / "models" / "qwen" / "f.bin").write_bytes(b"x") + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, + "run_console_subprocess") as run: + make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=False, + model_entries=self._entry_paths()) + out = buf.getvalue() + self.assertIn("[OK] Qwen3-TTS is already installed.", out) + self.assertIn("downloaded automatically", out) + self.assertIn("python {} install higgs_audio_tts_4b_q8_0".format( + self.manager), out) + self.assertNotIn("install qwen3_tts_1_7b_base_q8_0", out) + run.assert_not_called() - def test_decide_download_defaults_to_yes(self): - confirm = MagicMock(return_value=True) - make_server.models._decide_download(self.checkout, [], confirm) - self.assertIs(confirm.call_args[0][1], True) + def test_all_models_present_prints_no_commands(self): + for name in ("higgs", "qwen"): + target = self.checkout / "models" / name + target.mkdir(parents=True) + (target / "f.bin").write_bytes(b"x") + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, + "run_console_subprocess") as run: + rc = make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=False, + model_entries=self._entry_paths()) + out = buf.getvalue() + self.assertEqual(rc, 0) + self.assertIn("All selected models are already installed.", out) + self.assertNotIn("model_manager_v2.py install", out) + run.assert_not_called() - def test_decide_download_skips_prompt_when_all_models_present(self): + def test_download_skips_installed_models(self): + (self.checkout / "models" / "qwen").mkdir(parents=True) + (self.checkout / "models" / "qwen" / "f.bin").write_bytes(b"x") + with redirect_stdout(io.StringIO()), \ + patch.object(common, + "run_console_subprocess", + return_value=0) as run: + make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=True, + model_entries=self._entry_paths()) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args[0][0][3], "higgs_audio_tts_4b_q8_0") + + def test_entries_without_guidance_do_not_filter(self): + # A length mismatch means no filtering is possible: every model + # is treated as missing (the pre-change behavior). + buf = io.StringIO() + with redirect_stdout(buf): + make_server.models._install_models( + self.checkout, self.guidance, download=False, + model_entries=[{"path": "models/qwen"}]) + out = buf.getvalue() + self.assertIn("higgs_audio_tts_4b_q8_0", out) + self.assertIn("qwen3_tts_1_7b_base_q8_0", out) + + def test_download_applicable_false_without_manager(self): + self.manager.unlink() + self.assertFalse( + make_server.models.download_applicable(self.checkout, [])) + + def test_download_applicable_when_manager_present(self): + self.assertTrue( + make_server.models.download_applicable(self.checkout, [])) + + def test_download_applicable_skipped_when_all_models_present(self): target = self.checkout / "models" / "higgs" target.mkdir(parents=True) (target / "model.gguf").write_bytes(b"x") - confirm = MagicMock() - self.assertFalse(make_server.models._decide_download( - self.checkout, [{"path": "models/higgs"}], confirm)) - confirm.assert_not_called() + self.assertFalse(make_server.models.download_applicable( + self.checkout, [{"path": "models/higgs"}])) - def test_decide_download_prompts_when_a_model_is_missing(self): + def test_download_applicable_when_a_model_is_missing(self): target = self.checkout / "models" / "higgs" target.mkdir(parents=True) (target / "model.gguf").write_bytes(b"x") - confirm = MagicMock(return_value=True) - self.assertTrue(make_server.models._decide_download( + self.assertTrue(make_server.models.download_applicable( self.checkout, - [{"path": "models/higgs"}, {"path": "models/absent"}], - confirm)) - confirm.assert_called_once() + [{"path": "models/higgs"}, {"path": "models/absent"}])) def test_all_models_present_true_when_all_paths_hold_files(self): target = self.checkout / "models" / "higgs" @@ -729,6 +792,33 @@ class InstallModelsTests(unittest.TestCase): self.checkout, [{"path": "models/higgs"}])) +class TranscriptionChoicesTests(unittest.TestCase): + """_transcription_choices: the renamed voice-transcripts options.""" + + def test_fresh_directory_offers_the_renamed_all(self): + choices, default = make_server.wizard._transcription_choices( + [], {}, prompt_exists=False) + self.assertEqual(default, "all") + self.assertEqual(choices, [("Re-transcribe all", "all")]) + + def test_existing_transcripts_offer_new_only_and_all(self): + wavs = [Path("/x/narrator.wav"), Path("/x/new.wav")] + choices, default = make_server.wizard._transcription_choices( + wavs, {"narrator": "old transcript"}, prompt_exists=True) + self.assertEqual(default, "missing") + self.assertEqual([label for label, _mode in choices], + ["Only transcribe new voices", "Re-transcribe all"]) + + def test_complete_transcripts_offer_keep_and_all(self): + wavs = [Path("/x/narrator.wav")] + choices, default = make_server.wizard._transcription_choices( + wavs, {"narrator": "old transcript"}, prompt_exists=True) + self.assertEqual(default, "keep") + self.assertEqual([label for label, _mode in choices], + ["Keep the existing transcripts", + "Re-transcribe all"]) + + class TranscribeWavDirTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -1150,27 +1240,28 @@ class NonInteractiveMainTests(unittest.TestCase): ["Higgs-Audio-v3-TTS-4B-GGUF"]) self.assertNotIn("voice_dir", data) - def test_port_sync_accepted_updates_config(self): + def test_port_comes_from_config_and_leaves_config_alone(self): + # Ports are not a wizard question anymore: server.json always + # records the port in AUDIOCPP_API_URL (edited in Settings), and + # app/converter/config.py itself is never rewritten by setup. with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--port", "8080", + self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) self.assertEqual(exit_code, 0) - self.assertIn('"http://127.0.0.1:8080"', + self.assertIn('"http://127.0.0.1:9999"', self.fake_config.read_text(encoding="utf-8")) data = json.loads(self.output.read_text(encoding="utf-8")) - self.assertEqual(data["port"], 8080) + self.assertEqual(data["port"], 9999) - def test_port_sync_declined_keeps_config(self): - with patch.object(config, "AUDIOCPP_API_URL", - "http://127.0.0.1:9999"): - exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--port", "8080", - "--no-sync-port", "--no-sync-model-ids")) - self.assertEqual(exit_code, 0) - self.assertIn('"http://127.0.0.1:9999"', - self.fake_config.read_text(encoding="utf-8")) + def test_host_port_sync_flags_removed(self): + # No bind-host or port questions anywhere: 127.0.0.1 is fixed and + # the port follows Settings, so their flags are gone. + parser = make_server.wizard.build_parser() + for flag in ("--host", "--port", "--no-sync-port"): + with self.assertRaises(SystemExit): + parser.parse_args([flag, "x"]) def test_model_id_sync_accepted_updates_config(self): self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, @@ -1767,9 +1858,9 @@ class WizardNavigationTests(unittest.TestCase): def test_modify_flow_offers_build_when_not_built(self): # A server.json recording "vulkan" exists, but nothing is built: the - # wizard must still reach the backend menu (pre-selecting vulkan) and - # offer the build — instead of silently skipping it because the - # existing server.json already records a backend. + # combined config form must still ask the backend (pre-selecting + # vulkan) and offer the build — instead of silently skipping it + # because the existing server.json already records a backend. checkout = self._checkout() (checkout / "server.json").write_text( json.dumps({"models": [], "backend": "vulkan"}), @@ -1777,79 +1868,132 @@ class WizardNavigationTests(unittest.TestCase): catalog = make_server.catalog.load_model_catalog(checkout) supertonic = next(i for i, entry in enumerate(catalog) if entry["family"] == "supertonic") - confirm_questions = [] def fake_tree(*args, **kwargs): return [(supertonic, "Supertonic-GGUF")] - def fake_line_edit(stdscr, title, default, **kwargs): - if title == "Bind host": - return "127.0.0.1" - if title == "Port": - return "8080" - return default + captured = {} - def fake_menu(stdscr, title, options, **kwargs): - return "vulkan" - - def fake_confirm(stdscr, question, **kwargs): - confirm_questions.append(question) - return False # decline the build + def fake_form(stdscr, title, fields, **kwargs): + captured["title"] = title + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + return {f["key"]: f["value"] for f in fields} | { + "backend": by_key["backend"]["value"], + "build": False, # decline the build + } with patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ - patch.object(tui, "line_edit", side_effect=fake_line_edit), \ - patch.object(tui, "menu", side_effect=fake_menu), \ - patch.object(tui, "confirm", side_effect=fake_confirm): + patch.object(tui, "form", side_effect=fake_form): settings = make_server.wizard._wizard(None, self._args(), make_server.wizard.build_parser()) self.assertIsNotNone(settings) self.assertEqual(settings["backend"], "vulkan") self.assertFalse(settings["build"]) - # The build offer was shown (and declined); the old modify flow - # skipped it entirely. - self.assertTrue(any("not built for vulkan" in q - for q in confirm_questions)) - - def test_bind_host_esc_returns_to_families_tree(self): - # Esc on "Bind host" must fall back to the model-family tree, then - # re-selecting proceeds through the rest of the wizard. + # The config screen is one combined form (not one question per + # screen) that includes both the backend pick and the build offer. + self.assertEqual(captured["title"], "Configure audio.cpp") + self.assertIn("backend", captured["keys"]) + self.assertIn("build", captured["keys"]) + + def test_esc_on_config_form_returns_to_families_tree(self): + # Esc on the combined config form must fall back to the model-family + # tree; re-selecting then proceeds through the rest of the wizard. checkout = self._checkout() catalog = make_server.catalog.load_model_catalog(checkout) supertonic = next(i for i, entry in enumerate(catalog) if entry["family"] == "supertonic") tree_calls = [] - hosts = iter([make_server.wizard._GO_BACK, "127.0.0.1"]) + form_calls = [] def fake_tree(*args, **kwargs): tree_calls.append(1) return [(supertonic, "Supertonic-GGUF")] - def fake_line_edit(stdscr, title, default, **kwargs): - if title == "Bind host": - return next(hosts) - if title == "Port": - return "8080" - return default + def fake_form(stdscr, title, fields, **kwargs): + form_calls.append(title) + if len(form_calls) == 1: + return tui.Wizard.BACK # Esc on the config form + return {f["key"]: f["value"] for f in fields} with patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ - patch.object(tui, "line_edit", - side_effect=fake_line_edit), \ - patch.object(tui, "menu", return_value="cuda"), \ - patch.object(tui, "confirm", return_value=True): + patch.object(tui, "form", + side_effect=fake_form): settings = make_server.wizard._wizard(None, self._args(), make_server.wizard.build_parser()) self.assertIsNotNone(settings) - # The tree was re-shown after the host screen's Esc. + # The tree was re-shown after the form's Esc. self.assertEqual(len(tree_calls), 2) - self.assertEqual(settings["host"], "127.0.0.1") + self.assertEqual(form_calls, + ["Configure audio.cpp", "Configure audio.cpp"]) self.assertEqual([m["id"] for m in settings["model_entries"]], ["Supertonic-GGUF"]) + def test_combined_form_defaults_and_fixed_host_port(self): + # One screen collects everything: the form value defaults produce a + # complete settings dict whose host/port never came from questions. + checkout = self._checkout() + catalog = make_server.catalog.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + settings = make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + self.assertIsNotNone(settings) + self.assertEqual(settings["host"], "127.0.0.1") + self.assertEqual(settings["port"], + make_server.configsync.config_port()) + self.assertEqual(settings["backend"], "cuda") # default choice + self.assertTrue(settings["build"]) # not built yet → offered (default Yes) + self.assertFalse(settings["download"]) # no manager script here + self.assertTrue(settings["sync_model_ids"]) + + def test_build_offer_hidden_when_backend_already_built(self): + # A checkout with a built binary for the chosen backend must not + # show (or honor) a build offer. + checkout = self._checkout() + catalog = make_server.catalog.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + binary = checkout / "build" / "linux-cuda-release" / "bin" \ + / "audiocpp_server" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"x") + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + keys = [f["key"] for f in fields] + self.assertNotIn("build", keys) + self.assertNotIn("backend", keys) + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + settings = make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + self.assertIsNotNone(settings) + self.assertFalse(settings["build"]) + self.assertEqual(settings["backend"], "cuda") + class UninstallTests(unittest.TestCase): """uninstall: stop the server and remove the checkout.""" diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index b8a8e90..3b4e7f9 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -61,7 +61,8 @@ class GitCloneTests(unittest.TestCase): ["git", "clone", "url", "/t"]) def test_git_clone_streaming_adds_progress(self): - emit = lambda line: None + def emit(line): + pass with mock.patch.object(common, "run_console_subprocess", return_value=0) as run: self.assertEqual(common.git_clone("url", common.Path("/t"), diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 800acb0..c03f031 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -5,7 +5,6 @@ import sys import tempfile import threading import unittest -import contextlib from pathlib import Path from unittest.mock import patch @@ -126,37 +125,58 @@ class DecideFasterTranscriptionTests(unittest.TestCase): self._tmp.cleanup() def test_new_voices_default_to_missing_mode(self): - confirm = lambda q, default=True: True # noqa: E731 - plan = make_voices._decide_faster_transcription( + choices, default = make_voices._decide_faster_transcription( [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}, confirm) + {"narrator": {"ref_text": "old"}}) + self.assertEqual(default, "missing") + modes = [mode for _label, mode in choices] + self.assertIn("missing", modes) + self.assertIn("all", modes) + missing = [w for w in (self.narrator, self.new_voice) + if w.stem not in {"narrator"}] + plan = make_voices._plan_for("missing", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "missing") - self.assertEqual([w.name for w in plan["missing"]], ["new.wav"]) + self.assertEqual([w.name for w in plan["missing"]], + [w.name for w in missing]) def test_declining_new_voices_transcribes_all(self): - confirm = lambda q, default=True: False # noqa: E731 - plan = make_voices._decide_faster_transcription( + choices, default = make_voices._decide_faster_transcription( [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}, confirm) + {"narrator": {"ref_text": "old"}}) + # Re-transcribing everything stays available alongside new-only. + modes = [mode for _label, mode in choices] + self.assertIn("all", modes) + plan = make_voices._plan_for("all", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "all") - def test_no_new_voices_offers_retranscribe_default_no(self): - confirm = lambda q, default=True: default # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) - self.assertEqual(plan["mode"], "keep") - - def test_no_new_voices_accepted_retranscribes_all(self): - confirm = lambda q, default=True: True # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) + def test_no_new_voices_offers_retranscribe_default_keep(self): + choices, default = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + self.assertEqual(default, "keep") + modes = [mode for _label, mode in choices] + self.assertEqual(modes, ["keep", "all"]) + + def test_no_new_voices_can_retranscribe_all(self): + _choices, _default = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + plan = make_voices._plan_for("all", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "all") - def test_cancel_returns_none(self): - confirm = lambda q, default=True: None # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) - self.assertIsNone(plan) + def test_choice_labels_are_the_renamed_ones(self): + # The option names shown for the Voice-transcripts choice. + with_new, _ = make_voices._decide_faster_transcription( + [self.narrator, self.new_voice], + {"narrator": {"ref_text": "old"}}) + self.assertEqual([label for label, _mode in with_new], + ["Only transcribe new voices", "Re-transcribe all"]) + without_new, _ = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + self.assertEqual([label for label, _mode in without_new], + ["Keep the existing voices.json", + "Re-transcribe all"]) class MainTests(unittest.TestCase): @@ -273,8 +293,99 @@ class MainTests(unittest.TestCase): self.assertEqual(list(data), ["alpha", "narrator"]) -if __name__ == "__main__": - unittest.main() +class WizardFormTests(unittest.TestCase): + """The faster wizard: one combined form instead of a screen chain.""" + + def _args(self, *extra): + return make_voices.build_parser().parse_args(list(extra)) + + def _wavs(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + folder = Path(tmp.name) + (folder / "narrator.wav").write_bytes(b"x") + return folder + + def test_fresh_run_asks_one_form_without_transcription_choice(self): + folder = self._wavs() + captured = {} + + def fake_form(stdscr, title, fields, **kwargs): + captured["title"] = title + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + by_key["wav_dir"]["value"] = folder + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=fake_form) as mk_form: + settings = make_voices._wizard( + None, self._args("--output", str(folder / "voices.json"), + "--skip-install", "--skip-clone")) + self.assertIsNotNone(settings) + self.assertEqual(mk_form.call_count, 1) + self.assertEqual(captured["keys"], + ["wav_dir", "language", "whisper_model"]) + self.assertEqual(settings["wav_dir"], folder) + # Nothing was configured before, so everything is transcribed and + # no keep/new-only choice exists. + self.assertEqual(settings["plan"]["mode"], "all") + + def test_modify_run_offers_transcription_modes(self): + folder = self._wavs() + (folder / "new.wav").write_bytes(b"x") # a voice not in voices.json + output = folder / "voices.json" + existing = {"narrator": { + "ref_audio": str(folder / "narrator.wav"), + "ref_text": "old transcript", "language": "English"}} + output.write_text(json.dumps(existing), encoding="utf-8") + captured = {} + + def fake_form(stdscr, title, fields, **kwargs): + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + self.assertIn("transcription", by_key) + modes = [mode for _label, mode in by_key[ + "transcription"]["choices"](fields)] + # New .wavs exist, so both transcribing only those and + # re-transcribing everything are offered. + self.assertEqual(modes, ["missing", "all"]) + result = {f["key"]: f["value"] for f in fields} + result["transcription"] = "missing" + return result + + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=fake_form): + settings = make_voices._wizard( + None, self._args("--output", str(output), + "--skip-install", "--skip-clone")) + self.assertIsNotNone(settings) + self.assertEqual(captured["keys"], + ["wav_dir", "language", "whisper_model", + "transcription"]) + self.assertEqual(settings["plan"]["mode"], "missing") + self.assertEqual([w.name for w in settings["plan"]["missing"]], + ["new.wav"]) + self.assertEqual(settings["wav_dir"], folder) + + def test_cancel_aborts(self): + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=lambda *a, **k: k["back_value"]): + settings = make_voices._wizard( + None, self._args("--output", "/tmp/x.json", + "--skip-install", "--skip-clone")) + self.assertIsNone(settings) + + def test_port_flag_removed(self): + parser = make_voices.build_parser() + with self.assertRaises(SystemExit): + parser.parse_args(["--port", "8000"]) class SetupScreenTests(unittest.TestCase): diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 53e2897..e04d3fe 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch from converter import config from converter.clients import ( BACKEND_AUDIOCPP, - BACKEND_FASTER, BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 00bcc46..2c9c330 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -14,7 +14,6 @@ from contextlib import redirect_stdout from pathlib import Path from unittest.mock import MagicMock, patch -from converter import config from converter.clients import ( BACKEND_AUDIOCPP, BACKEND_FASTER, diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index d8a21bc..96e4706 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1238,7 +1238,6 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") - fields = self.tui.forms_seen[0][1] self.assertEqual(self._field("mode")["choices"], [("Clone from a .wav file", "clone")]) @@ -2075,7 +2074,8 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([step.title for step in steps], ["Uninstall qwen-tts"]) self.assertFalse(mk_run.call_args.kwargs["wait_on_finish"]) - emit = lambda line: None + def emit(line): + pass steps[0].work(emit, None) mk_uninstall.assert_called_once_with(emit=emit, cancel=None) self.assertIs(result, tui.Wizard.BACK) @@ -2213,7 +2213,8 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): steps = mk_run.call_args[0][2] self.assertEqual([step.title for step in steps], ["Download missing models"]) - emit = lambda line: None + def emit(line): + pass steps[0].work(emit, None) mk_install.assert_called_once_with( checkout, guidance, emit=emit, cancel=None) diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py index 6c984ad..b7f4389 100644 --- a/app/tests/test_taskview.py +++ b/app/tests/test_taskview.py @@ -570,7 +570,8 @@ class LanesViewTests(_FakeTui, unittest.TestCase): view.render() pane_w = (screen.width - 3) // 2 rects = [(1, pane_w), (1 + pane_w + 1, (screen.width - 3) - pane_w)] - for expected, (px, pw) in zip(("52/794", "45%"), rects): + for expected, (px, pw) in zip(("52/794", "45%"), rects, + strict=False): found = [(x, t) for _, x, t, _ in screen.strings if t == expected] self.assertEqual(len(found), 1, expected) x, label = found[0] diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index c2cd267..b15419b 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -753,6 +753,58 @@ class FormTests(TuiTestCase): result = tui.form(screen, "Settings", fields) self.assertEqual(result, {"fmt": "good", "other": "x"}) + def test_dir_field_opens_the_browser_and_saves_the_pick(self): + picked = Path("/picked/voices") + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + return_value=picked) as mk_browser: + # edit, Enter (focus jumped to Save after the pick). + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + mk_browser.assert_called_once() + self.assertEqual(Path(mk_browser.call_args[1]["start"]), + Path("/start")) + + def test_dir_pick_moves_focus_to_the_accept_button(self): + # Accepting a directory is a completed choice: focus lands on + # Save, so the very next Enter submits — no Tab hunting. + picked = Path("/picked/voices") + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + return_value=picked) as mk_browser: + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + mk_browser.assert_called_once() + + def test_dir_field_back_out_keeps_the_old_value(self): + # Backing out of the browser returns its back_value; the field + # keeps the previous path and focus stays on the fields (Tab + # then walks to Save). + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + side_effect=lambda *a, **k: k["back_value"]): + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": Path("/start")}) + + def test_dir_field_renders_its_path_and_fires_on_change(self): + picked = Path("/picked") + calls = [] + fields = [{"key": "voices", "label": "Voices", "kind": "dir", + "value": None, "on_change": lambda fs: calls.append(1)}] + with patch.object(tui, "browse_directory", return_value=picked): + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + self.assertEqual(calls, [1]) + texts = [text for _, _, text, _ in screen.strings] + self.assertTrue(any("Voices:" in text for text in texts)) + def _two_text_fields(self): return [{"key": "first", "label": "First", "kind": "text", "value": "a"}, -- cgit v1.2.3