aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/tests/test_hub.py')
-rw-r--r--app/tests/test_hub.py288
1 files changed, 284 insertions, 4 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index ef246ad..b1a91ed 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -10,7 +10,7 @@ import json
import tempfile
import unittest
from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
from backends import BackendInfo, BackendStatus, ServerSpec
from converter.clients import audiocpp as audiocpp_client
@@ -919,6 +919,18 @@ class ConvertFlowTests(unittest.TestCase):
remote_urls=remote_urls,
remote_models=list(remote_models or []))
+ def _mock_preflight(self, book="book.txt"):
+ """Replace the real books-folder scan with a canned plan.
+
+ Returns the mock so tests can assert how many per-model plans the
+ "All" flow computed (and with which name_tag/voice)."""
+ mk = MagicMock(return_value=([book], [(book, "planned")]))
+ patcher = patch.object(hub.AudiobookConverter, "preflight_overwrites",
+ mk)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ return mk
+
def test_audiocpp_remote_builds_one_form(self):
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
@@ -996,8 +1008,12 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(choices[1],
("a-much-longer-model-id".ljust(22)
+ " clone", "a-much-longer-model-id"))
- self.assertEqual({label.index("clone") for label, _ in choices},
- {29})
+ # Two configured models: the "All (multiple generation)" pick
+ # closes the menu, plain-text without capability columns.
+ self.assertEqual(choices[2],
+ ("All (multiple generation)", hub.AUDIOCPP_MODEL_ALL))
+ self.assertEqual({label.index("clone") for label, _ in choices
+ if "clone" in label}, {29})
self.assertEqual({label.index("tts") for label, _ in choices
if "tts" in label}, {24})
# A plain qwen3_tts entry (no CustomVoice in the id) is clone-only.
@@ -1056,7 +1072,8 @@ class ConvertFlowTests(unittest.TestCase):
choices = self._field("model_id")["choices"]
base = "Qwen3-TTS-12Hz-1.7B-Base-GGUF"
design = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"
- labels = {value: label for label, value in choices}
+ labels = {value: label for label, value in choices
+ if value != hub.AUDIOCPP_MODEL_ALL}
self.assertEqual(labels,
{base: base.ljust(len(design)) + " clone",
design: design.ljust(len(design))
@@ -1141,6 +1158,204 @@ class ConvertFlowTests(unittest.TestCase):
model_field["on_change"](fields)
self.assertEqual(voice_field["value"], "second")
+ # ------------------------------------------------------------------
+ # "All (multiple generation)" model pick
+ # ------------------------------------------------------------------
+
+ def test_model_menu_all_option_only_with_multiple_models(self):
+ # "All (multiple generation)" closes the model menu only when more
+ # than one model is configured: a single-model server has nothing
+ # to compare.
+ self._patch_remote([{"id": "solo", "family": "higgs_audio_tts",
+ "task": "tts"}], voices=["narrator"])
+ self._answer_form(backend="audiocpp-remote", model_id="solo",
+ audiocpp_voice="narrator", instructions="")
+ self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertEqual(self._field("model_id")["choices"],
+ [("solo tts clone", "solo")])
+ self._patch_remote(
+ [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"},
+ {"id": "beta", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ self._answer_form(backend="audiocpp-remote", model_id="alpha",
+ audiocpp_voice="narrator", instructions="")
+ self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ choices = self._field("model_id")["choices"]
+ self.assertEqual(choices[-1],
+ ("All (multiple generation)",
+ hub.AUDIOCPP_MODEL_ALL))
+ self.assertEqual(choices[0], ("alpha tts clone", "alpha"))
+
+ def test_all_pick_maps_one_run_per_model_with_the_picked_clone_voice(self):
+ # The "All" pick produces no single model/voice: the run receives
+ # the configured model list and each model's voice, with the
+ # picked server-side clone voice shared by every clone-capable
+ # model — and each model's overwrite plan computed with its
+ # model-tagged name.
+ self._patch_remote(
+ [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"},
+ {"id": "beta", "family": "chatterbox", "task": "tts"}],
+ voices=["narrator"])
+ mk_pre = self._mock_preflight()
+ self._answer_form(backend="audiocpp-remote",
+ model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="narrator", instructions="")
+ cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["model_ids"], ["alpha", "beta"])
+ self.assertEqual(kwargs["model_voices"],
+ {"alpha": "narrator", "beta": "narrator"})
+ self.assertNotIn("model_id", kwargs)
+ self.assertNotIn("voice", kwargs)
+ self.assertEqual(kwargs["api_url"], "http://audiocpp.local:8080")
+ self.assertEqual(mk_pre.call_count, 2)
+ self.assertEqual(mk_pre.call_args_list[0].kwargs["name_tag"],
+ "alpha")
+ self.assertEqual(mk_pre.call_args_list[1].kwargs["name_tag"], "beta")
+ self.assertEqual(kwargs["book_files"], ["book.txt"])
+ self.assertEqual(kwargs["planned_by_model"],
+ {"alpha": [("book.txt", "planned")],
+ "beta": [("book.txt", "planned")]})
+ self.assertNotIn("planned", kwargs)
+
+ def test_all_voice_falls_back_per_capability(self):
+ # A CustomVoice entry cannot clone: it synthesizes with a built-in
+ # speaker (the pick when it names one, the first speaker when it
+ # names a clone voice); the Base entry cannot take a speaker name
+ # and clones with the picked server voice (the first when the pick
+ # names a speaker).
+ self._patch_remote(
+ [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
+ "family": "qwen3_tts", "task": "tts"},
+ {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts",
+ "task": "tts"}],
+ voices=["narrator", "second"])
+ self._mock_preflight()
+ self._answer_form(
+ backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="narrator", instructions="")
+ cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertEqual(cmd[2]["model_voices"], {
+ "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF":
+ hub.QWEN3_TTS_SPEAKERS[0],
+ "Qwen3-TTS-12Hz-1.7B-Base-GGUF": "narrator"})
+ self._answer_form(
+ backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="Vivian", instructions="")
+ cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertEqual(cmd[2]["model_voices"], {
+ "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF": "Vivian",
+ "Qwen3-TTS-12Hz-1.7B-Base-GGUF": "narrator"})
+
+ def test_all_voice_union_offers_clone_voices_then_speakers(self):
+ # The Voice menu under "All" lists every model's clone voices
+ # first, then the built-in speakers while a speaker-capable model
+ # is configured; picking "All" keeps a voice the union offers.
+ self._patch_remote(
+ [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
+ "family": "qwen3_tts", "task": "tts"},
+ {"id": "alpha", "family": "chatterbox", "task": "tts"}],
+ voices=["narrator"])
+ self._mock_preflight()
+ self._answer_form(
+ backend="audiocpp-remote", model_id="alpha",
+ audiocpp_voice="narrator", instructions="")
+ self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("audiocpp_voice")
+ model_field = self._field("model_id")
+ self.assertTrue(voice_field["visible"](fields))
+ # Flip the Model pick to "All" (the form applies picks to the
+ # fields, firing on_change): the union lists every clone voice
+ # first, then the built-in speakers, and a clone pick survives.
+ voice_field["value"] = "narrator"
+ model_field["value"] = hub.AUDIOCPP_MODEL_ALL
+ model_field["on_change"](fields)
+ self.assertTrue(voice_field["visible"](fields))
+ self.assertEqual(voice_field["choices"](fields),
+ [(v, v) for v in
+ ["narrator"] + list(hub.QWEN3_TTS_SPEAKERS)])
+ self.assertEqual(voice_field["value"], "narrator")
+
+ def test_all_refuses_voice_design_model_without_instructions(self):
+ # A vdes entry in the "All" run needs the Instructions text its
+ # voice comes from: Generate! refuses with a pointed message
+ # instead of failing the run (or silently skipping the model).
+ self._patch_remote(
+ [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"},
+ {"id": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF",
+ "family": "qwen3_tts", "task": "vdes"}],
+ voices=["narrator"])
+ self._mock_preflight()
+ self._answer_form(
+ backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="narrator", instructions="")
+ self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("audiocpp_voice")
+ instructions_field = self._field("instructions")
+ # The form applies the "All" pick to the field before validating.
+ self._field("model_id")["value"] = hub.AUDIOCPP_MODEL_ALL
+ error = voice_field["validate"]("narrator")
+ self.assertIsNotNone(error)
+ self.assertIn("voice design", error)
+ self.assertIn("Instructions", error)
+ self.assertEqual(instructions_field["validate"](""), error)
+ self.assertIsNone(instructions_field["validate"]("A warm narrator"))
+
+ def test_all_refuses_clone_only_model_without_voices(self):
+ # A clone-only family (chatterbox) whose server lists no voices
+ # cannot run in the "All" set: Generate! refuses naming the model
+ # (with a description the run would go — instruction voice).
+ self._patch_remote(
+ [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"},
+ {"id": "beta", "family": "chatterbox", "task": "tts"}],
+ voices=[])
+ self._mock_preflight()
+ self._answer_form(
+ backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="", instructions="")
+ self._convert(None, [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("audiocpp_voice")
+ # The form applies the "All" pick to the field before validating.
+ self._field("model_id")["value"] = hub.AUDIOCPP_MODEL_ALL
+ error = voice_field["validate"]("")
+ self.assertIsNotNone(error)
+ self.assertIn("beta", error)
+ self.assertIn("Instructions", error)
+ # With a description the clone-only model designs its voice from
+ # it (instruction-voice mode): the run is accepted.
+ self._field("instructions")["value"] = "A warm narrator"
+ self.assertIsNone(voice_field["validate"](""))
+
+ def test_all_pick_works_on_the_managed_entry(self):
+ # The managed entry plans the same "All" run from server.json:
+ # voice_dir stems feed the union and the picked clone voice is
+ # shared by both clone-capable models.
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ (root / "server.json").write_text(json.dumps({
+ "models": [{"id": "qwen-1_7b", "family": "qwen3_tts",
+ "task": "tts"},
+ {"id": "qwen-0_6b", "family": "qwen3_tts",
+ "task": "tts"}],
+ "voice_dir": str(root),
+ }), encoding="utf-8")
+ (root / "Narrator.wav").write_bytes(b"x")
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=root):
+ self._mock_preflight()
+ self._answer_form(backend="audiocpp",
+ model_id=hub.AUDIOCPP_MODEL_ALL,
+ audiocpp_voice="Narrator", instructions="")
+ cmd = self._convert(None,
+ [self._ready("audiocpp", "audio.cpp")])
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["model_ids"], ["qwen-1_7b", "qwen-0_6b"])
+ self.assertEqual(kwargs["model_voices"],
+ {"qwen-1_7b": "Narrator", "qwen-0_6b": "Narrator"})
+
def test_audiocpp_local_model_switch_keeps_the_picked_voice(self):
# The managed entry's voice list is shared by every model in
# server.json, so switching models keeps the picked voice.
@@ -2359,6 +2574,71 @@ class PreflightTests(unittest.TestCase):
with self.assertRaises(hub._BackToForm):
confirm("overwrite?", True)
+ # -- "All (multiple generation)": one plan per model ----------------
+
+ def _all_cmd(self):
+ return ("convert", "audiocpp", {
+ "model_ids": ["m1", "m2"],
+ "model_voices": {"m1": "narrator", "m2": None},
+ "output_format": "mp3", "clone": None})
+
+ def test_all_run_plans_each_model_and_stashes_planned_by_model(self):
+ stdscr = object()
+ cmd = self._all_cmd()
+ with patch.object(hub.AudiobookConverter, "preflight_overwrites",
+ side_effect=[(["book.txt"],
+ [("book.txt", "book_m1_narrator")]),
+ (["book.txt"],
+ [("book.txt", "book_m2_designed")])]) \
+ as mk_pre:
+ self.assertTrue(hub._preflight(stdscr, cmd))
+ self.assertEqual(mk_pre.call_count, 2)
+ first, second = mk_pre.call_args_list
+ # Each model plans with its own voice (so its narrator tag — and
+ # therefore its overwrite questions — match the real run) and its
+ # model-tagged output name.
+ self.assertEqual(first.kwargs["voice"], "narrator")
+ self.assertEqual(first.kwargs["voice_mode"],
+ hub.voice_mode_for("audiocpp", "narrator",
+ None, None))
+ self.assertEqual(first.kwargs["name_tag"], "m1")
+ self.assertIsNone(second.kwargs["voice"])
+ self.assertEqual(second.kwargs["voice_mode"],
+ hub.voice_mode_for("audiocpp", None, None, None))
+ self.assertEqual(second.kwargs["name_tag"], "m2")
+ self.assertEqual(cmd[2]["book_files"], ["book.txt"])
+ self.assertEqual(cmd[2]["planned_by_model"],
+ {"m1": [("book.txt", "book_m1_narrator")],
+ "m2": [("book.txt", "book_m2_designed")]})
+ self.assertNotIn("planned", cmd[2])
+
+ def test_all_run_no_books_flashes_and_returns_false(self):
+ stdscr = object()
+ with patch.object(hub.AudiobookConverter, "preflight_overwrites",
+ return_value=([], [])), \
+ patch.object(hub.tui, "flash") as mk_flash:
+ self.assertFalse(hub._preflight(stdscr, self._all_cmd()))
+ mk_flash.assert_called_once()
+
+ def test_all_run_all_skipped_flashes_and_returns_false(self):
+ stdscr = object()
+ with patch.object(hub.AudiobookConverter, "preflight_overwrites",
+ return_value=(["book.txt"], [])), \
+ patch.object(hub.tui, "flash") as mk_flash:
+ self.assertFalse(hub._preflight(stdscr, self._all_cmd()))
+ mk_flash.assert_called_once()
+
+ def test_all_run_confirm_esc_raises_back_to_form(self):
+ stdscr = object()
+ with patch.object(hub.AudiobookConverter, "preflight_overwrites",
+ return_value=(["book.txt"],
+ [("book.txt", "x")])) as mk_pre:
+ hub._preflight(stdscr, self._all_cmd())
+ confirm = mk_pre.call_args.kwargs["confirm"]
+ with patch.object(hub.tui, "confirm", return_value=hub._CANCEL):
+ with self.assertRaises(hub._BackToForm):
+ confirm("overwrite?", True)
+
class DispatchConversionTests(unittest.TestCase):
"""_Hub._run_conversion: builds the config and runs the run view."""