diff options
| author | historia <historiavg@proton.me> | 2026-08-23 20:15:54 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-23 20:15:54 -0400 |
| commit | 07f7b351f2956b6c92761877c9a4314bcede3b6e (patch) | |
| tree | 7104cb82db092caf76c6e7204a055aa65cd7c384 /tests | |
| parent | df57cf2733e398473a58d788cd97fea3a618f892 (diff) | |
| download | tts-audiobook-generator-07f7b351f2956b6c92761877c9a4314bcede3b6e.tar.gz | |
feat: improve tui library aesthetics for make_audiocpp_server_json.py
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_make_audiocpp_server_json.py | 509 | ||||
| -rw-r--r-- | tests/test_tui.py | 473 |
2 files changed, 953 insertions, 29 deletions
diff --git a/tests/test_make_audiocpp_server_json.py b/tests/test_make_audiocpp_server_json.py index ca17e93..39d32a8 100644 --- a/tests/test_make_audiocpp_server_json.py +++ b/tests/test_make_audiocpp_server_json.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch from converter import config from tools import make_audiocpp_server_json as make_server +from tools import tui FAKE_CONFIG = ( 'LANGUAGE = "English"\n' @@ -142,6 +143,68 @@ class FindWavFilesTests(unittest.TestCase): self.assertEqual(make_server.find_wav_files(self.folder), []) +class DetectWavDirTests(unittest.TestCase): + """Shallow .wav-directory discovery across the two checkout roots.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.root = Path(self._td.name) + self.audiocpp = self.root / "audio.cpp" + self.tts_root = self.root / "tts-audiobook-generator" + self.audiocpp.mkdir() + self.tts_root.mkdir() + + def tearDown(self): + self._td.cleanup() + + def _wav_dir(self, where, name="voices"): + directory = where / name + directory.mkdir(parents=True, exist_ok=True) + (directory / "voice.wav").write_bytes(b"x") + return directory + + def test_unique_wav_dir_in_tts_root_returned(self): + found = self._wav_dir(self.tts_root, "voices") + self.assertEqual(make_server.detect_wav_dir(self.audiocpp, + self.tts_root), + found) + + def test_unique_wav_dir_in_audiocpp_root_returned(self): + found = self._wav_dir(self.audiocpp, "reference") + self.assertEqual(make_server.detect_wav_dir(self.audiocpp, + self.tts_root), + found) + + def test_root_itself_containing_wavs_returned(self): + (self.tts_root / "direct.wav").write_bytes(b"x") + self.assertEqual(make_server.detect_wav_dir(self.audiocpp, + self.tts_root), + self.tts_root) + + def test_multiple_wav_dirs_returns_none(self): + self._wav_dir(self.tts_root, "one") + self._wav_dir(self.audiocpp, "two") + self.assertIsNone(make_server.detect_wav_dir(self.audiocpp, + self.tts_root)) + + def test_output_dir_of_tts_root_excluded(self): + self._wav_dir(self.tts_root, "output") + self.assertIsNone(make_server.detect_wav_dir(self.audiocpp, + self.tts_root)) + + def test_no_wavs_returns_none(self): + self.assertIsNone(make_server.detect_wav_dir(self.audiocpp, + self.tts_root)) + + def test_nested_wav_dir_not_seen(self): + # Shallow search only: a wav dir two levels deep is not a candidate. + nested = self.tts_root / "outer" / "inner" + nested.mkdir(parents=True) + (nested / "voice.wav").write_bytes(b"x") + self.assertIsNone(make_server.detect_wav_dir(self.audiocpp, + self.tts_root)) + + class ConfigPortTests(unittest.TestCase): def test_port_parsed_from_config_url(self): with patch.object(config, "AUDIOCPP_API_URL", @@ -310,6 +373,40 @@ class NormalizeDirArgTests(unittest.TestCase): self.assertEqual(result, Path("/tmp/foo").resolve()) +class CheckoutAutoSelectTests(unittest.TestCase): + """TUI browser auto-accept callback for an audio.cpp checkout.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.root = Path(self._td.name) + + def tearDown(self): + self._td.cleanup() + + def test_accepts_audio_cpp_containing_model_specs(self): + checkout = self.root / "audio.cpp" + checkout.mkdir() + (checkout / "model_specs").mkdir() + self.assertEqual(make_server._checkout_auto_select(checkout), + checkout) + + def test_rejects_audio_cpp_without_model_specs(self): + checkout = self.root / "audio.cpp" + checkout.mkdir() + self.assertIsNone(make_server._checkout_auto_select(checkout)) + + def test_rejects_other_name_even_with_model_specs(self): + other = self.root / "not-audiocpp" + other.mkdir() + (other / "model_specs").mkdir() + self.assertIsNone(make_server._checkout_auto_select(other)) + + def test_rejects_plain_directory(self): + plain = self.root / "somewhere" + plain.mkdir() + self.assertIsNone(make_server._checkout_auto_select(plain)) + + class DefaultModelIdTests(unittest.TestCase): def test_preferred_ids_for_tested_families(self): self.assertEqual(make_server.default_model_id("qwen3_tts"), "qwen") @@ -348,17 +445,13 @@ class LoadModelCatalogTests(unittest.TestCase): self.assertNotIn("empty_tts", [entry["family"] for entry in catalog]) - def test_tested_families_come_first_in_order(self): - catalog = make_server.load_model_catalog(self.checkout) - tested = [entry["family"] for entry in catalog - if entry["tested"]] - self.assertEqual(tested, list(make_server.TESTED_FAMILIES)) - - def test_non_tested_families_follow_alphabetically(self): + def test_families_sorted_alphabetically_by_display_name(self): catalog = make_server.load_model_catalog(self.checkout) - non_tested = [entry["family"] for entry in catalog - if not entry["tested"]] - self.assertEqual(non_tested, sorted(non_tested)) + names = [entry["display_name"].lower() for entry in catalog] + self.assertEqual(names, sorted(names)) + # No family is marked "tested" anymore; all are treated equally. + self.assertNotIn("tested", catalog[0]) + self.assertNotIn("TESTED_FAMILIES", dir(make_server)) def test_default_package_and_target_directory_resolved(self): catalog = make_server.load_model_catalog(self.checkout) @@ -395,6 +488,96 @@ class LoadModelCatalogTests(unittest.TestCase): make_server.load_model_catalog(empty) +class DetectBackendTests(unittest.TestCase): + """Backend detection from audio.cpp build directory names.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + + def tearDown(self): + self._td.cleanup() + + def _build(self, name, binary="audiocpp_server"): + build_dir = self.checkout / "build" / name + bin_dir = build_dir / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / binary).write_bytes(b"x") + return build_dir + + def test_no_build_dir_returns_none(self): + self.assertIsNone(make_server.detect_backend(self.checkout)) + + def test_unique_linux_backend_detected(self): + self._build("linux-cuda-release") + self.assertEqual(make_server.detect_backend(self.checkout), "cuda") + + def test_windows_exe_backend_detected(self): + self._build("windows-vulkan-debug", binary="audiocpp_server.exe") + self.assertEqual(make_server.detect_backend(self.checkout), "vulkan") + + def test_hip_backend_detected(self): + self._build("linux-hip-release") + self.assertEqual(make_server.detect_backend(self.checkout), "hip") + + def test_cpu_backend_detected(self): + self._build("linux-cpu-release") + self.assertEqual(make_server.detect_backend(self.checkout), "cpu") + + def test_metal_maps_to_cpu(self): + self._build("macos-metal-release") + self.assertEqual(make_server.detect_backend(self.checkout), "cpu") + + def test_multiple_backends_returns_none(self): + self._build("linux-cuda-release") + self._build("linux-cpu-release") + self.assertIsNone(make_server.detect_backend(self.checkout)) + + def test_multiple_builds_same_backend_detected(self): + self._build("linux-cuda-release") + self._build("windows-cuda-debug") + self.assertEqual(make_server.detect_backend(self.checkout), "cuda") + + def test_build_dir_without_binary_ignored(self): + (self.checkout / "build" / "linux-cuda-release").mkdir(parents=True) + self.assertIsNone(make_server.detect_backend(self.checkout)) + + def test_non_matching_build_dir_name_ignored(self): + self._build("linux-mybuild-release") + self.assertIsNone(make_server.detect_backend(self.checkout)) + + +class BackendOptionsTests(unittest.TestCase): + """Aligned backend menu labels and the [auto-detected] default.""" + + def test_options_have_aligned_dashes(self): + options, default_index = make_server._backend_options() + dash_columns = {label.index(" - ") for label, _ in options} + self.assertEqual(len(dash_columns), 1) + self.assertEqual(default_index, 0) + + def test_detected_backend_marked_and_defaulted(self): + options, default_index = make_server._backend_options("vulkan") + labels = [label for label, _ in options] + self.assertEqual(default_index, labels.index(next( + label for label, value in options + if value == "vulkan" and label.endswith("[auto-detected]")))) + self.assertTrue(labels[default_index].endswith("[auto-detected]")) + self.assertEqual(options[default_index][1], "vulkan") + + def test_unknown_detected_backend_is_ignored(self): + options, default_index = make_server._backend_options("opencl") + self.assertEqual(default_index, 0) + self.assertFalse(any("[auto-detected]" in label + for label, _ in options)) + + def test_labels_keep_backend_values(self): + options, _ = make_server._backend_options() + self.assertEqual([value for _, value in options], + list(make_server.BACKENDS)) + + class AskFamiliesTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -418,25 +601,25 @@ class AskFamiliesTests(unittest.TestCase): [self.catalog[0]["family"]]) def test_comma_separated_numbers(self): - # 1 and 3 (qwen3_tts and voxcpm2 in the tested-first ordering). + # 1 and 3 (higgs_audio_tts and pocket_tts in alphabetical order). with patch("builtins.input", side_effect=["1,3"]): chosen = make_server.ask_families(self.catalog) - self.assertEqual(chosen, ["qwen3_tts", "voxcpm2"]) + self.assertEqual(chosen, ["higgs_audio_tts", "pocket_tts"]) def test_space_separated_numbers(self): with patch("builtins.input", side_effect=["2 4"]): chosen = make_server.ask_families(self.catalog) - self.assertEqual(chosen, ["higgs_audio_tts", "index_tts2"]) + self.assertEqual(chosen, ["index_tts2", "qwen3_tts"]) def test_dedupes_repeated_choices(self): with patch("builtins.input", side_effect=["1,1,2"]): chosen = make_server.ask_families(self.catalog) - self.assertEqual(chosen, ["qwen3_tts", "higgs_audio_tts"]) + self.assertEqual(chosen, ["higgs_audio_tts", "index_tts2"]) def test_invalid_input_reprompts(self): with patch("builtins.input", side_effect=["foo", "0", "2"]): chosen = make_server.ask_families(self.catalog) - self.assertEqual(chosen, ["higgs_audio_tts"]) + self.assertEqual(chosen, ["index_tts2"]) class BuildServerConfigTests(unittest.TestCase): @@ -475,6 +658,83 @@ class BuildServerConfigTests(unittest.TestCase): self.assertEqual(entry["mode"], "offline") +class InstallModelsTests(unittest.TestCase): + """Printing or auto-running the model install commands.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + self.manager = self.checkout / "tools" / "model_manager_v2.py" + self.manager.parent.mkdir() + self.manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + self.guidance = [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")] + + def tearDown(self): + self._td.cleanup() + + def test_declined_download_prints_commands_deduped(self): + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(make_server.subprocess, "run") as run: + make_server._install_models(self.checkout, self.guidance, + download=False) + out = buf.getvalue() + self.assertEqual(out.count("install higgs_audio_tts_4b_q8_0"), 1) + self.assertEqual(out.count("install qwen3_tts_1_7b_base_q8_0"), 1) + run.assert_not_called() + + def test_accepted_download_runs_each_command(self): + with patch.object(make_server.subprocess, "run", + return_value=MagicMock(returncode=0)) as run: + make_server._install_models(self.checkout, self.guidance, + download=True) + self.assertEqual(run.call_count, 2) + commands = [call[0][0] for call in run.call_args_list] + self.assertEqual(commands[0], + [sys.executable, str(self.manager), "install", + "higgs_audio_tts_4b_q8_0"]) + self.assertEqual(commands[1], + [sys.executable, str(self.manager), "install", + "qwen3_tts_1_7b_base_q8_0"]) + for call in run.call_args_list: + self.assertEqual(call[1]["cwd"], str(self.checkout)) + + def test_missing_manager_falls_back_to_printing(self): + self.manager.unlink() + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(make_server.subprocess, "run") as run: + make_server._install_models(self.checkout, self.guidance, + download=True) + self.assertIn("install higgs_audio_tts_4b_q8_0", buf.getvalue()) + run.assert_not_called() + + def test_failed_install_reports_warning_and_continues(self): + results = iter([MagicMock(returncode=1), MagicMock(returncode=0)]) + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(make_server.subprocess, "run", + side_effect=lambda *a, **k: next(results)) as run: + make_server._install_models(self.checkout, self.guidance, + download=True) + 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._decide_download(self.checkout, confirm)) + confirm.assert_not_called() + + def test_decide_download_asks_when_manager_present(self): + confirm = MagicMock(return_value=True) + self.assertTrue(make_server._decide_download(self.checkout, confirm)) + confirm.assert_called_once() + + class TranscribeWavDirTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -697,6 +957,28 @@ class MainTests(_MainTestBase): make_server.main() self.assertEqual(ctx.exception.code, 2) + def test_wav_prompt_defaults_to_detected_dir(self): + # No --wavs: the prompt default is the unique .wav directory detected + # across the checkouts; pressing Enter accepts it. + (self.folder / "narrator.wav").write_bytes(b"x") + argv = ["make_audiocpp_server_json.py", + "--output", str(self.output), + "--audiocpp-dir", str(self.checkout)] + # wav(Enter -> default), family, host, port, backend, lazy, sync(y) + inputs = ["", "", "", "", "", "", "y"] + with patch.object(sys, "argv", argv), \ + patch("builtins.input", side_effect=inputs), \ + patch.object(make_server, "detect_wav_dir", + return_value=self.folder), \ + patch.object(make_server, "transcribe_reference_audio", + side_effect=lambda path, model_name="base": "t"), \ + patch.object(make_server, "whisper_backend_available", + return_value="faster_whisper"): + exit_code = make_server.main() + self.assertEqual(exit_code, 0) + data = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(data["voice_dir"], str(self.folder.resolve())) + def test_default_run_hosts_recommended_entry(self): exit_code = self._run(self._args(), inputs=self._defaults()) self.assertEqual(exit_code, 0) @@ -706,10 +988,11 @@ class MainTests(_MainTestBase): self.assertEqual(data["backend"], "cuda") # Single family -> one entry, lazy defaults to False. self.assertFalse(data["lazy_load"]) - self.assertEqual([model["id"] for model in data["models"]], ["qwen"]) + # The first family alphabetically is Higgs Audio v3 TTS 4B. + self.assertEqual([model["id"] for model in data["models"]], ["higgs"]) self.assertEqual( [model["path"] for model in data["models"]], - ["models/Qwen3-TTS-12Hz-1.7B-Base-GGUF"]) + ["models/Higgs-Audio-v3-TTS-4B-GGUF"]) self.assertEqual(data["models"][0]["task"], "tts") # voice_dir only when wavs are present; this run has none. self.assertNotIn("voice_dir", data) @@ -799,6 +1082,22 @@ class MainTests(_MainTestBase): self.assertEqual(data["backend"], "cpu") self.assertTrue(data["lazy_load"]) + def test_final_output_is_wrote_plus_install_commands(self): + # Two families -> two entries; the console output ends with the Wrote + # line and one full-path install command per model, nothing else. + code, out = self._run_capturing( + self._args("--families", "qwen3_tts,higgs_audio_tts"), + inputs=["", "", "", ""]) + self.assertEqual(code, 0) + self.assertIn(f"Wrote {self.output.resolve()} with 2 entries.", out) + manager = self.checkout / "tools" / "model_manager_v2.py" + self.assertIn(f"python {manager} install qwen3_tts_1_7b_base_q8_0", + out) + self.assertIn(f"python {manager} install higgs_audio_tts_4b_q8_0", out) + # The generated JSON and the old [INFO] notes are no longer echoed. + self.assertNotIn("[INFO]", out) + self.assertNotIn('"models"', out) + def test_missing_wav_dir_flag_errors_with_message(self): missing = self.root / "nope" with self.assertRaises(SystemExit) as ctx, \ @@ -823,16 +1122,50 @@ class MainTests(_MainTestBase): code = make_server.main() return code, buf.getvalue() + def test_detected_backend_selected_by_default(self): + # A built backend in the checkout's build/ dir makes that backend the + # default; pressing Enter on the backend prompt accepts it. + build_dir = self.checkout / "build" / "linux-vulkan-release" / "bin" + build_dir.mkdir(parents=True) + (build_dir / "audiocpp_server").write_bytes(b"x") + # host, port, backend(Enter -> detected vulkan), lazy, sync(y) + inputs = ["", "", "", "", "", "y"] + code, out = self._run_capturing(self._args(), inputs=inputs) + self.assertEqual(code, 0) + data = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(data["backend"], "vulkan") + + def test_download_accepted_runs_install_commands(self): + manager = self.checkout / "tools" / "model_manager_v2.py" + manager.parent.mkdir(parents=True, exist_ok=True) + manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + argv = ["make_audiocpp_server_json.py"] + self._args( + "--families", "qwen3_tts") + with patch.object(sys, "argv", argv), \ + patch("builtins.input", side_effect=["", "", "", "", "y"]), \ + patch.object(make_server, "transcribe_reference_audio"), \ + patch.object(make_server, "whisper_backend_available", + return_value="faster_whisper"), \ + patch.object(make_server.subprocess, "run", + return_value=MagicMock(returncode=0)) as run: + code = make_server.main() + self.assertEqual(code, 0) + run.assert_called_once_with( + [sys.executable, str(manager), "install", + "qwen3_tts_1_7b_base_q8_0"], + cwd=str(self.checkout)) + def test_all_packages_design_hosts_vdes_entry(self): # --all-packages: pick the VoiceDesign package (menu 3) and accept the # "design" default so it is hosted with task "vdes". self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, encoding="utf-8") - # family, packages(3=VoiceDesign), task(design default Enter), host, + # packages(3=VoiceDesign), task(design default Enter), host, # port, backend, lazy, sync(y) - inputs = ["", "3", "", "", "", "", "", "y"] + inputs = ["3", "", "", "", "", "", "y"] code, out = self._run_capturing( - self._args("--all-packages"), inputs=inputs) + self._args("--families", "qwen3_tts", "--all-packages"), + inputs=inputs) self.assertEqual(code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(data["models"], [{ @@ -847,9 +1180,6 @@ class MainTests(_MainTestBase): self.assertIn("install qwen3_tts_1_7b_voicedesign_q8_0", out) self.assertNotIn("install qwen3_tts_1_7b_customvoice_q8_0", out) self.assertNotIn("install qwen3_tts_1_7b_base_q8_0", out) - # Usage guidance points at the --instructions flow. - self.assertIn("--model qwen-design", out) - self.assertIn("--instructions", out) # Single-entry server: the converter ids are synced to the entry. text = self.fake_config.read_text(encoding="utf-8") self.assertIn('AUDIOCPP_MODEL_ID = "qwen-design"', text) @@ -858,9 +1188,11 @@ class MainTests(_MainTestBase): def test_all_packages_non_design_package_gets_tts_no_prompt(self): # CustomVoice (menu 2) is not a design package -> task "tts" with no # task prompt. - inputs = ["", "2", "", "", "", "", "y"] + # packages(2=CustomVoice), host, port, backend, lazy, sync(y) + inputs = ["2", "", "", "", "", "y"] code, _ = self._run_capturing( - self._args("--all-packages"), inputs=inputs) + self._args("--families", "qwen3_tts", "--all-packages"), + inputs=inputs) self.assertEqual(code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(data["models"], [{ @@ -874,11 +1206,11 @@ class MainTests(_MainTestBase): def test_all_packages_both_tts_and_design(self): # Pick Base (recommended) + VoiceDesign -> two entries; the design # package prompts for its task. - # family, packages(1,3), task(design default Enter), host, port, - # backend, lazy - inputs = ["", "1,3", "", "", "", "", ""] + # packages(1,3), task(design default Enter), host, port, backend, lazy + inputs = ["1,3", "", "", "", "", ""] code, _ = self._run_capturing( - self._args("--all-packages"), inputs=inputs) + self._args("--families", "qwen3_tts", "--all-packages"), + inputs=inputs) self.assertEqual(code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual([model["id"] for model in data["models"]], @@ -1251,6 +1583,125 @@ class PromptTextReuseTests(_MainTestBase): self.assertIn("narrator|Fresh.", prompt.read_text(encoding="utf-8")) +class WizardBackNavigationTests(unittest.TestCase): + """Esc-driven back navigation in the _wizard step state machine. + + The tui widget module is mocked so the steps can be driven directly; + Esc is simulated by having the mocked widgets return the _GO_BACK + sentinel (what the real widgets return for Esc via back_value). + """ + + def _settings(self, tmp, confirm_sequence): + checkout = _make_checkout(tmp) + manager = checkout / "tools" / "model_manager_v2.py" + manager.parent.mkdir(parents=True, exist_ok=True) + manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + wav_dir = tmp / "wavs" + wav_dir.mkdir() + tui_mock = MagicMock() + tui_mock.menu.return_value = "cuda" + tui_mock.line_edit.side_effect = ["127.0.0.1", "8080"] + tui_mock.browse_directory.return_value = wav_dir + tui_mock.confirm.side_effect = confirm_sequence + args = argparse.Namespace( + audiocpp_dir=checkout, families="qwen3_tts", + all_packages=False, host=None, port=None, backend=None, + lazy_load=False, output=None, force=False, input_dir=None, + whisper_model="base") + with patch.object(make_server, "_load_tui", + return_value=tui_mock): + return make_server._wizard(None, args, None), tui_mock, checkout + + def test_esc_on_download_prompt_returns_to_previous_step(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + # confirms: lazy(True), download Esc(_GO_BACK), download accept(True). + settings, tui_mock, _ = self._settings( + Path(tmp.name), [True, make_server._GO_BACK, True]) + self.assertIsNotNone(settings) + self.assertEqual(settings["backend"], "cuda") + downloads = [call[0][1] for call in tui_mock.confirm.call_args_list + if call[0][1] == "Automatically download the selected " + "models with model_manager_v2.py now?"] + self.assertEqual(len(downloads), 2, + "Esc on the download prompt must re-show it after " + "going back") + + def test_esc_back_to_wav_browser_rebrowses(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + checkout = _make_checkout(root) + wav_dir = root / "wavs" + wav_dir.mkdir() + (wav_dir / "narrator.wav").write_bytes(b"x") + prompt = wav_dir / make_server.PROMPT_TEXT_FILENAME + prompt.write_text("narrator|already transcribed.\n", encoding="utf-8") + tui_mock = MagicMock() + tui_mock.menu.return_value = "cuda" + tui_mock.line_edit.side_effect = ["127.0.0.1", "8080"] + # 1st browse, then re-browse after Esc backs from the transcription + # plan, then browse again only if we re-reached step 4 once more. + tui_mock.browse_directory.side_effect = [wav_dir, wav_dir] + # lazy(True), transcription Esc(_GO_BACK), lazy(True again), + # transcription(True), download(True). + tui_mock.confirm.side_effect = [ + True, make_server._GO_BACK, True, True, True] + args = argparse.Namespace( + audiocpp_dir=checkout, families="qwen3_tts", + all_packages=False, host=None, port=None, backend=None, + lazy_load=False, output=None, force=False, input_dir=None, + whisper_model="base") + with patch.object(make_server, "_load_tui", return_value=tui_mock): + settings = make_server._wizard(None, args, None) + self.assertIsNotNone(settings) + self.assertEqual(tui_mock.browse_directory.call_count, 2, + "Esc on the transcription plan must re-open the " + "wav browser") + + def test_esc_on_first_step_aborts_wizard(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + checkout = _make_checkout(Path(tmp.name)) + tui_mock = MagicMock() + tui_mock.browse_directory.side_effect = tui.WizardCancelled + args = argparse.Namespace( + audiocpp_dir=None, families="qwen3_tts", + all_packages=False, host=None, port=None, backend=None, + lazy_load=False, output=None, force=False, input_dir=None, + whisper_model="base") + with patch.object(make_server, "_load_tui", return_value=tui_mock), \ + self.assertRaises(tui.WizardCancelled): + make_server._wizard(None, args, None) + + def test_wav_browser_starts_in_detected_wav_dir(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + checkout = _make_checkout(root) + # The only .wav directory across the checkout (and the real + # TTS_ROOT, which has none) is voices/ inside the checkout. + voices = checkout / "voices" + voices.mkdir() + (voices / "narrator.wav").write_bytes(b"x") + wav_dir = root / "wavs" + wav_dir.mkdir() + tui_mock = MagicMock() + tui_mock.menu.return_value = "cuda" + tui_mock.line_edit.side_effect = ["127.0.0.1", "8080"] + tui_mock.browse_directory.return_value = wav_dir + tui_mock.confirm.side_effect = [True, True] + args = argparse.Namespace( + audiocpp_dir=checkout, families="qwen3_tts", + all_packages=False, host=None, port=None, backend=None, + lazy_load=False, output=None, force=False, input_dir=None, + whisper_model="base") + with patch.object(make_server, "_load_tui", return_value=tui_mock): + make_server._wizard(None, args, None) + start = tui_mock.browse_directory.call_args[1].get("start") + self.assertEqual(start, voices.resolve()) + + class ModeSelectionTests(unittest.TestCase): """Choosing between the TUI wizard and the line prompts.""" diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..94fb638 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,473 @@ +"""Tests for the DOS-style curses TUI widgets in tools/tui.py. + +The widget module imports curses lazily, so these tests swap the +curses module for a small fake (patched into sys.modules) and drive +the widgets with scripted keys against a recording screen. That works +without a terminal and lets the tests assert exact drawing +coordinates: theme colors, the left margin of list rows, and that no +row ever paints over the dialog border. +""" + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tools import tui + + +class FakeCurses: + """Minimal curses stand-in: attributes, key codes, color pairs.""" + + A_BOLD = 1 + A_DIM = 2 + A_REVERSE = 4 + + COLOR_BLACK = 0 + COLOR_RED = 1 + COLOR_GREEN = 2 + COLOR_YELLOW = 3 + COLOR_BLUE = 4 + COLOR_MAGENTA = 5 + COLOR_CYAN = 6 + COLOR_WHITE = 7 + + KEY_DOWN = 0x101 + KEY_UP = 0x102 + KEY_LEFT = 0x103 + KEY_RIGHT = 0x104 + KEY_HOME = 0x105 + KEY_END = 0x106 + KEY_PPAGE = 0x107 + KEY_NPAGE = 0x108 + KEY_BACKSPACE = 0x109 + KEY_BTAB = 0x10A + + ACS_ULCORNER = "ul" + ACS_URCORNER = "ur" + ACS_LLCORNER = "ll" + ACS_LRCORNER = "lr" + ACS_VLINE = "v" + ACS_HLINE = "h" + + class error(Exception): + pass + + def __init__(self): + self.pairs = {} # pair number -> (fg, bg) + self.colors = True + + def has_colors(self): + return self.colors + + def start_color(self): + pass + + def init_pair(self, number, fg, bg): + self.pairs[number] = (fg, bg) + + def color_pair(self, number): + return number << 8 + + def curs_set(self, visibility): + pass + + +class FakeScreen: + """Recording curses window; getch() replays scripted keys.""" + + def __init__(self, keys=(), width=80, height=24): + self.keys = list(keys) + self.width = width + self.height = height + self.strings = [] # (y, x, text, attr) from addstr + self.chars = [] # (y, x, ch, attr) from addch + + def getmaxyx(self): + return self.height, self.width + + def erase(self): + pass + + def refresh(self): + pass + + def bkgd(self, ch, attr): + pass + + def addstr(self, y, x, text, attr=0): + self.strings.append((y, x, text, attr)) + + def addch(self, y, x, ch, attr=0): + self.chars.append((y, x, ch, attr)) + + def hline(self, y, x, ch, n, attr=0): + pass + + def getch(self): + if not self.keys: + raise AssertionError("the script ran out of keys") + return self.keys.pop(0) + + +class TuiTestCase(unittest.TestCase): + """Base class: fresh theme + fake curses module for every test.""" + + def setUp(self): + tui._THEME.clear() + self.curses = FakeCurses() + self.screen = FakeScreen() + patcher = patch.dict(sys.modules, {"curses": self.curses}) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(tui._THEME.clear) + + def dialog_box(self, screen=None): + """(x0, x_right) border columns of the drawn dialog.""" + screen = screen or self.screen + corners = screen.chars[:2] + self.assertEqual(corners[0][2], FakeCurses.ACS_ULCORNER) + self.assertEqual(corners[1][2], FakeCurses.ACS_URCORNER) + return corners[0][1], corners[1][1] + + def assert_inside_border(self, screen=None): + """No drawn string may reach the right border column.""" + screen = screen or self.screen + _, x_right = self.dialog_box(screen) + for _, x, text, _ in screen.strings: + self.assertLessEqual( + x + len(text), x_right, + f"{text!r} painted over the border at x={x}") + + +class ThemeTests(TuiTestCase): + def test_desktop_and_message_backgrounds_are_black(self): + frame = tui.Frame(self.screen, "Title", "footer") + theme, pairs = frame.theme, self.curses.pairs + for name in ("desktop", "border", "title", "ok", "warn", "err", + "info", "input", "check", "accent"): + fg, bg = pairs[theme[name] >> 8] + self.assertEqual(bg, FakeCurses.COLOR_BLACK, name) + + def test_cursor_bar_and_active_button_stand_out(self): + frame = tui.Frame(self.screen, "Title", "footer") + theme, pairs = frame.theme, self.curses.pairs + fg, bg = pairs[theme["bar"] >> 8] + self.assertEqual((fg, bg), + (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_CYAN)) + fg, bg = pairs[theme["btn_on"] >> 8] + self.assertEqual((fg, bg), + (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_GREEN)) + + def test_without_colors_theme_uses_plain_attributes(self): + self.curses.colors = False + frame = tui.Frame(self.screen, "Title", "footer") + self.assertEqual(self.curses.pairs, {}) + self.assertEqual(frame.theme["desktop"], 0) + self.assertEqual(frame.theme["title"], FakeCurses.A_BOLD) + + +class MenuTests(TuiTestCase): + OPTIONS = [("first option", "one"), ("second option", "two")] + + def test_option_rows_left_justified_help_centered(self): + screen = FakeScreen(keys=[10]) + value = tui.menu(screen, "Pick one", self.OPTIONS, + help_lines=["Help text"]) + self.assertEqual(value, "one") + x0, x_right = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + for label in ("first option", "second option"): + x = next(x for _, x, text, _ in screen.strings if text == label) + self.assertEqual(x, margin, label) + inner_w = x_right - x0 - 1 + help_x = next(x for _, x, text, _ in screen.strings + if text == "Help text") + self.assertEqual(help_x, x0 + 1 + (inner_w - len("Help text")) // 2) + self.assertGreater(help_x, margin) + self.assert_inside_border(screen) + + def test_up_wraps_around_to_last_option(self): + screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10]) + value = tui.menu(screen, "Pick one", self.OPTIONS) + self.assertEqual(value, "two") + + def test_no_option_painted_over_the_border(self): + screen = FakeScreen(keys=[FakeCurses.KEY_END, 10]) + tui.menu(screen, "Pick", [("a" * 60, "a"), ("b", "b")]) + self.assert_inside_border(screen) + + def test_empty_options_rejected(self): + with self.assertRaises(ValueError): + tui.menu(self.screen, "Pick", []) + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.menu(screen, "Pick", self.OPTIONS, back_value=marker), + marker) + + def test_q_still_aborts_with_back_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + with self.assertRaises(tui.WizardCancelled): + tui.menu(screen, "Pick", self.OPTIONS, back_value=marker) + + +class ConfirmTests(TuiTestCase): + def test_tab_switches_and_enter_activates(self): + screen = FakeScreen(keys=[9, 10]) + self.assertFalse(tui.confirm(screen, "Overwrite?", default=True)) + + def test_y_answers_directly(self): + screen = FakeScreen(keys=[ord("y")]) + self.assertTrue(tui.confirm(screen, "Overwrite?", default=False)) + + def test_enter_takes_the_default(self): + screen = FakeScreen(keys=[10]) + self.assertTrue(tui.confirm(screen, "Overwrite?", default=True)) + + def test_esc_aborts_without_cancel_value(self): + screen = FakeScreen(keys=[27]) + with self.assertRaises(tui.WizardCancelled): + tui.confirm(screen, "Overwrite?", default=True) + + def test_esc_returns_cancel_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.confirm(screen, "Overwrite?", default=True, + cancel_value=marker), + marker) + + def test_q_returns_cancel_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + self.assertIs( + tui.confirm(screen, "Overwrite?", default=True, + cancel_value=marker), + marker) + + +class LineEditTests(TuiTestCase): + def test_typing_backspace_and_enter(self): + keys = [ord("c"), ord("d"), FakeCurses.KEY_BACKSPACE, 10] + screen = FakeScreen(keys=keys) + value = tui.line_edit(screen, "Edit", "ab") + self.assertEqual(value, "abc") + + def test_validation_error_keeps_editing(self): + keys = [ord("x"), 10, FakeCurses.KEY_BACKSPACE, 10] + screen = FakeScreen(keys=keys) + value = tui.line_edit( + screen, "Edit", "5", + validate=lambda s: None if s.isdigit() else "digits only") + self.assertEqual(value, "5") + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.line_edit(screen, "Edit", "text", back_value=marker), + marker) + + def test_q_stays_typeable_with_back_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q"), 10]) + value = tui.line_edit(screen, "Edit", "te", back_value=marker) + self.assertEqual(value, "teq") + + +def _accept_audio_cpp(entry: Path): + """auto_select callback that accepts an 'audio.cpp' checkout root.""" + if entry.name == "audio.cpp" and (entry / "model_specs").is_dir(): + return entry + return None + + +class BrowseDirectoryTests(TuiTestCase): + def setUp(self): + super().setUp() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + for name in ("alpha", "beta", "zulu"): + (self.root / name).mkdir() + (self.root / "noise.txt").write_text("x", encoding="utf-8") + + def _checkout_tree(self): + """A temp dir containing an 'audio.cpp' checkout + a sibling dir.""" + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + (root / "audio.cpp").mkdir() + (root / "audio.cpp" / "model_specs").mkdir() + (root / "other").mkdir() + return root + + def test_listing_rows_left_justified(self): + screen = FakeScreen(keys=[10]) + chosen = tui.browse_directory(screen, "Pick", start=self.root) + self.assertEqual(chosen, self.root.resolve()) + x0, x_right = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + for label in ("[ Use this directory ]", "..", + "alpha/", "beta/", "zulu/"): + x = next(x for _, x, text, _ in screen.strings if text == label) + self.assertEqual(x, margin, label) + drawn = " ".join(text for _, _, text, _ in screen.strings) + self.assertNotIn("noise.txt", drawn) + self.assert_inside_border(screen) + + def test_enter_opens_highlighted_subdirectory(self): + keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10, 10] + screen = FakeScreen(keys=keys) + chosen = tui.browse_directory(screen, "Pick", start=self.root) + self.assertEqual(chosen, (self.root / "alpha").resolve()) + self.assert_inside_border(screen) + + def test_enter_auto_accepts_matching_subdir(self): + root = self._checkout_tree() + # sel 0 = [ Use this directory ], 1 = .., 2 = audio.cpp/ + keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10] + screen = FakeScreen(keys=keys) + chosen = tui.browse_directory(screen, "Pick", start=root, + auto_select=_accept_audio_cpp) + self.assertEqual(chosen, (root / "audio.cpp").resolve()) + + def test_right_auto_accepts_matching_subdir(self): + root = self._checkout_tree() + keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_RIGHT] + screen = FakeScreen(keys=keys) + chosen = tui.browse_directory(screen, "Pick", start=root, + auto_select=_accept_audio_cpp) + self.assertEqual(chosen, (root / "audio.cpp").resolve()) + + def test_use_this_directory_ignores_auto_select(self): + # Enter on '[ Use this directory ]' must accept the listed dir + # without ever consulting auto_select. + root = self._checkout_tree() + calls = [] + + def callback(entry): + calls.append(entry) + return entry # would auto-accept any subdir if consulted + + screen = FakeScreen(keys=[10]) + chosen = tui.browse_directory(screen, "Pick", start=root, + auto_select=callback) + self.assertEqual(chosen, root.resolve()) + self.assertEqual(calls, []) + + def test_auto_select_returning_none_descends_normally(self): + # A non-matching subdir (or a None reply) keeps browsing: Enter + # descends into it, then '[ Use this directory ]' accepts it. + root = self._checkout_tree() + calls = [] + + def callback(entry): + calls.append(entry) + return None + + # sel 0 = use, 1 = .., 2 = audio.cpp/, 3 = other/ + keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10, 10] + screen = FakeScreen(keys=keys) + chosen = tui.browse_directory(screen, "Pick", start=root, + auto_select=callback) + self.assertEqual(chosen, (root / "other").resolve()) + # auto_select was consulted only for the highlighted 'other/' row. + self.assertEqual([p.name for p in calls], ["other"]) + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.browse_directory(screen, "Pick", start=self.root, + back_value=marker), + marker) + + def test_q_still_aborts_with_back_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + with self.assertRaises(tui.WizardCancelled): + tui.browse_directory(screen, "Pick", start=self.root, + back_value=marker) + + +class CheckboxTreeTests(TuiTestCase): + FAMILIES = [ + {"label": "Family one", "detail": "tts", + "options": [{"key": "pkg-a", "label": "pkg-a", "recommended": True}, + {"key": "pkg-b", "label": "pkg-b", "recommended": False}]}, + {"label": "Family two", "detail": "tts, cloning", + "options": [{"key": "pkg-c", "label": "pkg-c", "recommended": True}]}, + ] + + def test_nothing_selected_by_default(self): + # Nothing is pre-checked: Enter alone flashes and waits, and a + # selection only happens after Space checks an option. The first + # Enter and the flash each consume a key. + screen = FakeScreen(keys=[10, 10, ord(" "), 10]) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + self.assertEqual(picked, [(0, "pkg-a")]) + + def test_rows_left_justified_inside_the_border(self): + screen = FakeScreen(keys=[ord(" "), 10]) + tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + x0, x_right = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + strings = sorted((y, x, text) for y, x, text, _ in screen.strings) + # Family row: its checkbox starts at the margin. + y_family = next(y for y, _, text in strings if text == "- Family one") + family_box_x = next(x for y, x, text in strings + if y == y_family and text == "[x] ") + self.assertEqual(family_box_x, margin) + # Option row: its checkbox sits one indent (2 columns) deeper. + option_box_x = next(x for y, x, text in strings + if text == "[x] " and y != y_family) + self.assertEqual(option_box_x, margin + 4) + self.assert_inside_border(screen) + + def test_long_indented_options_do_not_paint_over_the_border(self): + # A wide screen keeps the dialog width driven by the option row + # itself (not the footer), the geometry where the old centered + # segments drawing could paint over the right border. + families = [{"label": "F", "detail": "tts", + "options": [{"key": "long", "label": "x" * 60, + "recommended": True}]}] + screen = FakeScreen(keys=[ord(" "), 10], width=120) + picked = tui.checkbox_tree(screen, "Pick", families) + self.assertEqual(picked, [(0, "long")]) + self.assert_inside_border(screen) + + def test_space_checks_then_enter_accepts(self): + screen = FakeScreen(keys=[ord(" "), 10]) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + self.assertEqual(picked, [(0, "pkg-a")]) + + def test_empty_families_rejected(self): + with self.assertRaises(ValueError): + tui.checkbox_tree(self.screen, "Pick", []) + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + back_value=marker), + marker) + + def test_q_still_aborts_with_back_value(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + with self.assertRaises(tui.WizardCancelled): + tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + back_value=marker) + + +if __name__ == "__main__": + unittest.main() |
