aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_make_audiocpp_server_json.py509
-rw-r--r--tests/test_tui.py473
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()