aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 15:12:40 -0400
committerhistoria <historiavg@proton.me>2026-08-24 15:12:40 -0400
commitafd1c67d92c7f32389d5f652b9fa71530538a16f (patch)
tree04179b54524d433446a2d5bfe17bdcc9a8de98f4 /app/tests
parent7ee1d4bb63c12982ec4900ec870ad96baba4b22b (diff)
downloadtts-audiobook-generator-afd1c67d92c7f32389d5f652b9fa71530538a16f.tar.gz
feat: fold backend menu into conversion settings menu
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_hub.py191
1 files changed, 139 insertions, 52 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index d244144..f40b72d 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -291,6 +291,15 @@ class SubmenuStatusTableTests(unittest.TestCase):
return fake_menu
+ def _capture_form(self, captured):
+ def fake_form(stdscr, title, fields, **kwargs):
+ captured["title"] = title
+ captured["fields"] = fields
+ captured.update(kwargs)
+ return hub._GO_BACK # Cancel: back out immediately
+
+ return fake_form
+
def test_setup_menu_lists_bare_labels_and_status_table(self):
captured = {}
infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0),
@@ -319,22 +328,23 @@ class SubmenuStatusTableTests(unittest.TestCase):
("qwen-tts", "running [remote]", "ok", "body")])
self.assertIsNone(captured["notice_lines"])
- def test_convert_menu_shows_status_table(self):
+ def test_convert_menu_builds_one_form_with_backend_field(self):
captured = {}
st = BackendStatus("qwen", "qwen-tts", installed=True,
configured=True)
- with patch.object(hub.tui, "menu", self._capture_menu(captured)), \
+ with patch.object(hub.tui, "form",
+ self._capture_form(captured)), \
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._convert_menu(None, [st])
self.assertIsNone(result)
- self.assertEqual(captured["title"], "Convert books with...")
- # Only convertible backends are listed — no "Set up a backend"
- # detour inside the Convert flow.
- self.assertEqual([label for label, _ in captured["options"]],
- ["qwen-tts"])
- self.assertEqual(
- captured["table_rows"], [("qwen-tts", "installed", "warn",
- "body")])
+ self.assertEqual(captured["title"], "Convert books")
+ # One form, no picker menu: the first field is the Backend picker,
+ # and only convertible backends are offered in it.
+ self.assertEqual(captured["fields"][0]["key"], "backend")
+ self.assertEqual(captured["fields"][0]["choices"],
+ [("qwen-tts", "qwen")])
+ self.assertEqual(captured["buttons"], ("Generate!", "Cancel"))
+ self.assertTrue(captured["start_on_buttons"])
def test_convert_with_nothing_ready_flashes_instead_of_menu(self):
# Installed-but-unconfigured → nothing convertible: a hint flash
@@ -424,8 +434,8 @@ class SubmenuStatusTableTests(unittest.TestCase):
class ConvertFlowTests(unittest.TestCase):
- """_convert_audiocpp / _convert_qwen / _convert_faster: each backend
- collects its settings on a single form (local config or live remote
+ """_convert_menu: one form whose first field is the Backend picker,
+ followed by that backend's options (local config or live remote
queries)."""
def setUp(self):
@@ -449,6 +459,10 @@ class ConvertFlowTests(unittest.TestCase):
_, fields, _ = self.tui.forms_seen[form_index]
return next(f for f in fields if f["key"] == key)
+ def _ready(self, key, label):
+ """A backend status that is ready to convert with."""
+ return BackendStatus(key, label, installed=True, configured=True)
+
# ------------------------------------------------------------------
# audio.cpp: remote server (no local checkout / server.json)
# ------------------------------------------------------------------
@@ -472,9 +486,11 @@ class ConvertFlowTests(unittest.TestCase):
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="higgs", voice="narrator",
- instructions="", speed="1.5")
- cmd = hub._convert_audiocpp(None)
+ self._answer_form(backend="audiocpp", model_id="higgs",
+ audiocpp_voice="narrator", instructions="",
+ speed="1.5")
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP)
kwargs = cmd[2]
@@ -488,14 +504,17 @@ class ConvertFlowTests(unittest.TestCase):
# One form, not a cascade of menus/editors.
self.assertEqual(len(self.tui.forms_seen), 1)
title, fields, form_kwargs = self.tui.forms_seen[0]
- self.assertEqual(title, "Convert with audio.cpp")
+ self.assertEqual(title, "Convert books")
self.assertEqual([f["key"] for f in fields],
- ["model_id", "voice", "instructions",
- "output_format", "speed", "single_file", "debug"])
+ ["backend", "model_id", "audiocpp_voice",
+ "instructions", "output_format", "speed",
+ "single_file", "debug"])
self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel"))
self.assertTrue(form_kwargs["start_on_buttons"])
+ # The backend field offers the ready backend.
+ self.assertEqual(fields[0]["choices"], [("audio.cpp", "audiocpp")])
# The model menu was fed from the live query (label, id).
- self.assertEqual(fields[0]["choices"],
+ self.assertEqual(self._field("model_id")["choices"],
[("higgs (higgs_audio_tts, tts)", "higgs")])
def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self):
@@ -503,13 +522,15 @@ class ConvertFlowTests(unittest.TestCase):
[{"id": "qwen", "family": "qwen3_tts", "task": "tts"}],
voices=["narrator"])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="qwen", voice="(built-in speaker)",
+ self._answer_form(backend="audiocpp", model_id="qwen",
+ audiocpp_voice="(built-in speaker)",
instructions="")
- cmd = hub._convert_audiocpp(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
# The sentinel maps to "no voice" (built-in speaker).
self.assertIsNone(cmd[2]["voice"])
fields = self.tui.forms_seen[0][1]
- voice_field = self._field("voice")
+ voice_field = self._field("audiocpp_voice")
choices = voice_field["choices"](fields)
self.assertEqual(choices,
[("(built-in speaker)", "(built-in speaker)"),
@@ -521,9 +542,11 @@ class ConvertFlowTests(unittest.TestCase):
self._patch_remote([{"id": "legacy", "family": "", "task": ""}],
voices=[])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="legacy",
- voice="(built-in speaker)", instructions="")
- cmd = hub._convert_audiocpp(None)
+ self._answer_form(backend="audiocpp", model_id="legacy",
+ audiocpp_voice="(built-in speaker)",
+ instructions="")
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertIsNotNone(cmd)
self.assertIsNone(cmd[2]["voice"])
@@ -531,13 +554,15 @@ class ConvertFlowTests(unittest.TestCase):
self._patch_remote(
[{"id": "design", "family": "qwen3_tts", "task": "vdes"}])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="design", voice=None,
+ self._answer_form(backend="audiocpp", model_id="design",
+ audiocpp_voice=None,
instructions="A warm British narrator")
- cmd = hub._convert_audiocpp(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertIsNone(cmd[2]["voice"])
self.assertEqual(cmd[2]["instructions"], "A warm British narrator")
fields = self.tui.forms_seen[0][1]
- voice_field = self._field("voice")
+ voice_field = self._field("audiocpp_voice")
self.assertFalse(voice_field["visible"](fields))
instr = self._field("instructions")
self.assertIsNotNone(instr["validate"](""))
@@ -549,22 +574,25 @@ class ConvertFlowTests(unittest.TestCase):
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="higgs", voice="narrator",
- instructions="")
- hub._convert_audiocpp(None)
- voice_field = self._field("voice")
+ self._answer_form(backend="audiocpp", model_id="higgs",
+ audiocpp_voice="narrator", instructions="")
+ hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
+ voice_field = self._field("audiocpp_voice")
self.assertIsNotNone(voice_field["validate"](""))
self.assertIsNone(voice_field["validate"]("narrator"))
def test_audiocpp_remote_unreachable_models_flash_and_abort(self):
self._patch_remote(None) # endpoint did not answer valid JSON
- cmd = hub._convert_audiocpp(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertIsNone(cmd)
self.assertIn("Could not list models", self.tui.flashes[0])
def test_audiocpp_remote_empty_models_flash_and_abort(self):
self._patch_remote([])
- cmd = hub._convert_audiocpp(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertIsNone(cmd)
self.assertIn("hosts no model entries", self.tui.flashes[0])
@@ -575,12 +603,14 @@ class ConvertFlowTests(unittest.TestCase):
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=[])
with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="higgs", voice="", instructions="")
- cmd = hub._convert_audiocpp(None)
+ self._answer_form(backend="audiocpp", model_id="higgs",
+ audiocpp_voice="", instructions="")
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertIsNotNone(cmd)
self.assertIsNone(cmd[2]["voice"])
fields = self.tui.forms_seen[0][1]
- voice_field = self._field("voice")
+ voice_field = self._field("audiocpp_voice")
self.assertEqual(voice_field["choices"](fields), [])
# ------------------------------------------------------------------
@@ -607,9 +637,10 @@ class ConvertFlowTests(unittest.TestCase):
patch.object(hub.audiocpp_backend, "fetch_server_models",
must_not_query), \
patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
- self._answer_form(model_id="qwen", voice="Narrator",
- instructions="")
- cmd = hub._convert_audiocpp(None)
+ self._answer_form(backend="audiocpp", model_id="qwen",
+ audiocpp_voice="Narrator", instructions="")
+ cmd = hub._convert_menu(None,
+ [self._ready("audiocpp", "audio.cpp")])
self.assertEqual(queried, [])
self.assertIsNotNone(cmd)
self.assertEqual(cmd[2]["model_id"], "qwen")
@@ -636,9 +667,11 @@ class ConvertFlowTests(unittest.TestCase):
with patch.object(hub.qwen_backend, "QWEN_SPEAKERS",
["Vivian", "Serena"]), \
patch.object(hub.config, "SPEAKER", "Vivian"):
- self._answer_form(mode="custom", speaker="Serena", clone="")
+ self._answer_form(backend="qwen", mode="custom", speaker="Serena",
+ clone="")
with patch.object(hub.common, "update_config_value") as mk_update:
- cmd = hub._convert_qwen(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("qwen", "qwen-tts")])
speaker_in_memory = hub.config.SPEAKER
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], hub.BACKEND_QWEN)
@@ -648,8 +681,8 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(speaker_in_memory, "Serena")
fields = self.tui.forms_seen[0][1]
self.assertEqual([f["key"] for f in fields],
- ["mode", "speaker", "clone", "output_format",
- "speed", "single_file", "debug"])
+ ["backend", "mode", "speaker", "clone",
+ "output_format", "speed", "single_file", "debug"])
mode_field = self._field("mode")
self.assertEqual(mode_field["choices"],
[("Built-in speaker", "custom"),
@@ -666,10 +699,11 @@ class ConvertFlowTests(unittest.TestCase):
def test_qwen_clone_mode_passes_path_and_keeps_speaker(self):
with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \
patch.object(hub.config, "SPEAKER", "Vivian"):
- self._answer_form(mode="clone", speaker="Vivian",
+ self._answer_form(backend="qwen", mode="clone", speaker="Vivian",
clone="/tmp/ref.wav")
with patch.object(hub.common, "update_config_value") as mk_update:
- cmd = hub._convert_qwen(None)
+ cmd = hub._convert_menu(None,
+ [self._ready("qwen", "qwen-tts")])
self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav")
# Clone mode does not touch the global speaker.
mk_update.assert_not_called()
@@ -682,12 +716,13 @@ class ConvertFlowTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as td:
with patch.object(hub.faster_backend, "_checkout",
return_value=Path(td)):
- self._answer_form(voice="obama")
- cmd = hub._convert_faster(None)
+ self._answer_form(backend="faster", faster_voice="obama")
+ cmd = hub._convert_menu(
+ None, [self._ready("faster", "faster-qwen3-tts")])
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], "faster")
self.assertEqual(cmd[2]["voice"], "obama")
- self.assertEqual(self._field("voice")["kind"], "text")
+ self.assertEqual(self._field("faster_voice")["kind"], "text")
def test_faster_local_still_lists_voices_json(self):
with tempfile.TemporaryDirectory() as td:
@@ -696,14 +731,66 @@ class ConvertFlowTests(unittest.TestCase):
json.dumps({"default": {}, "obama": {}}), encoding="utf-8")
with patch.object(hub.faster_backend, "_checkout",
return_value=checkout):
- self._answer_form(voice="obama")
- cmd = hub._convert_faster(None)
+ self._answer_form(backend="faster", faster_voice="obama")
+ cmd = hub._convert_menu(
+ None, [self._ready("faster", "faster-qwen3-tts")])
self.assertEqual(cmd[2]["voice"], "obama")
- voice_field = self._field("voice")
+ voice_field = self._field("faster_voice")
self.assertEqual(voice_field["kind"], "choice")
self.assertEqual(voice_field["choices"],
[("default", "default"), ("obama", "obama")])
+ # ------------------------------------------------------------------
+ # multiple backends: the Backend picker gates which options show
+ # ------------------------------------------------------------------
+
+ def test_multiple_backends_gate_options_on_backend_value(self):
+ # Two ready backends: the form leads with a Backend picker and the
+ # per-backend fields are hidden/shown by its value.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="qwen", mode="custom", speaker="Vivian",
+ clone="")
+ cmd = hub._convert_menu(None, [
+ self._ready("audiocpp", "audio.cpp"),
+ self._ready("qwen", "qwen-tts")])
+ self.assertEqual(cmd[0], "convert")
+ self.assertEqual(cmd[1], hub.BACKEND_QWEN)
+ fields = self.tui.forms_seen[0][1]
+ self.assertEqual(fields[0]["key"], "backend")
+ self.assertEqual(fields[0]["choices"],
+ [("audio.cpp", "audiocpp"), ("qwen-tts", "qwen")])
+ self.assertEqual(
+ [f["key"] for f in fields],
+ ["backend", "model_id", "audiocpp_voice", "instructions",
+ "mode", "speaker", "clone", "output_format", "speed",
+ "single_file", "debug"])
+ # The form opens on the configured default (audio.cpp): its fields
+ # show, the other backend's hide.
+ for key in ("model_id", "audiocpp_voice", "instructions"):
+ self.assertTrue(self._field(key)["visible"](fields))
+ for key in ("mode", "speaker", "clone"):
+ self.assertFalse(self._field(key)["visible"](fields))
+ # Picking qwen in the Backend field swaps which options show.
+ fields[0]["value"] = "qwen"
+ self.assertTrue(self._field("mode")["visible"](fields))
+ self.assertTrue(self._field("speaker")["visible"](fields))
+ self.assertFalse(self._field("clone")["visible"](fields))
+ # qwen's clone mode hides the speaker and shows the .wav path.
+ self._field("mode")["value"] = "clone"
+ self.assertFalse(self._field("speaker")["visible"](fields))
+ self.assertTrue(self._field("clone")["visible"](fields))
+ for key in ("model_id", "audiocpp_voice", "instructions"):
+ self.assertFalse(self._field(key)["visible"](fields))
+ # And back to audio.cpp.
+ fields[0]["value"] = "audiocpp"
+ for key in ("model_id", "audiocpp_voice", "instructions"):
+ self.assertTrue(self._field(key)["visible"](fields))
+ for key in ("mode", "speaker", "clone"):
+ self.assertFalse(self._field(key)["visible"](fields))
+
class SelectSpecTests(unittest.TestCase):
"""_select_spec: mode-aware server selection (qwen has two servers)."""