aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 17:46:48 -0400
committerhistoria <historiavg@proton.me>2026-08-26 17:46:48 -0400
commit6ccb6d443d2fb871b43d96ea61a95bc3e6a92355 (patch)
treed692ddccd6ec212cf4ebabb25a85e83af479d0fe /app/tests
parentc147087c9d4707bffaeee58d390653637a21cce8 (diff)
downloadtts-audiobook-generator-6ccb6d443d2fb871b43d96ea61a95bc3e6a92355.tar.gz
feat: combined install/configure tui screens into one menu, removed extraneous wizard screens
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_backends.py36
-rw-r--r--app/tests/test_backends_audiocpp.py300
-rw-r--r--app/tests/test_backends_common.py3
-rw-r--r--app/tests/test_backends_faster.py161
-rw-r--r--app/tests/test_converter.py1
-rw-r--r--app/tests/test_converter_progress.py1
-rw-r--r--app/tests/test_hub.py7
-rw-r--r--app/tests/test_taskview.py3
-rw-r--r--app/tests/test_tui.py52
9 files changed, 446 insertions, 118 deletions
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"},