aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_backends.py6
-rw-r--r--app/tests/test_backends_audiocpp.py296
-rw-r--r--app/tests/test_backends_faster.py77
-rw-r--r--app/tests/test_hub.py270
-rw-r--r--app/tests/test_tui.py31
5 files changed, 644 insertions, 36 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index acee6b6..0e260be 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -31,13 +31,11 @@ class RegistryTests(unittest.TestCase):
keys = [info.key for info in REGISTRY]
self.assertEqual(keys, ["audiocpp", "qwen", "faster"])
- def test_every_entry_has_detect_and_setup_tui(self):
+ def test_every_entry_has_detect_setup_and_uninstall(self):
for info in REGISTRY:
self.assertTrue(callable(info.detect), info.key)
self.assertTrue(callable(info.setup_tui), info.key)
- self.assertIsInstance(info.configure_actions, list)
- for action in info.configure_actions:
- self.assertTrue(callable(action.run))
+ self.assertTrue(callable(info.uninstall), info.key)
def test_get_returns_entry_by_key(self):
self.assertIs(get("audiocpp").key, "audiocpp")
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 563ed78..3d042db 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -1270,5 +1270,301 @@ class DetectServerSpecTests(unittest.TestCase):
for line in status.details))
+class InstalledModelEntriesTests(unittest.TestCase):
+ """installed_model_entries: the complement of missing_model_entries."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _server_json(self, models):
+ path = self.dir / "server.json"
+ path.write_text(json.dumps({"models": models}), encoding="utf-8")
+ return path
+
+ def test_lists_entries_whose_files_are_on_disk(self):
+ (self.dir / "models" / "present").mkdir(parents=True)
+ (self.dir / "models" / "present" / "m.gguf").write_bytes(b"x")
+ path = self._server_json([
+ {"id": "a", "path": "models/present"},
+ {"id": "b", "path": "models/absent"},
+ ])
+ installed = make_server.installed_model_entries(path)
+ self.assertEqual([m["id"] for m in installed], ["a"])
+
+ def test_unreadable_json_returns_empty(self):
+ path = self.dir / "server.json"
+ path.write_text("not json", encoding="utf-8")
+ self.assertEqual(make_server.installed_model_entries(path), [])
+
+
+class MissingModelInstallGuidanceTests(unittest.TestCase):
+ """missing_model_install_guidance: missing paths -> (id, install_id)."""
+
+ def test_maps_paths_and_skips_unmapped(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ specs = checkout / "model_specs"
+ specs.mkdir()
+ (specs / "qwen3_tts.json").write_text(json.dumps({
+ "family": "qwen3_tts", "category": "tts",
+ "tasks": ["tts"],
+ "packages": [{
+ "id": "qwen3_tts_0_6b_base_q8_0", "format": "gguf",
+ "target_directory": "Qwen3-TTS-12Hz-0.6B-Base-GGUF",
+ }],
+ }), encoding="utf-8")
+ missing = [
+ {"id": "qwen", "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"},
+ {"id": "x", "rel": "models/nope"},
+ ]
+ guidance = make_server.missing_model_install_guidance(
+ checkout, missing)
+ self.assertEqual(guidance,
+ [("qwen", "qwen3_tts_0_6b_base_q8_0")])
+
+
+class LoadServerConfigTests(unittest.TestCase):
+ """load_server_config: read server.json, or None when unusable."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_reads_dict_document(self):
+ path = self.dir / "server.json"
+ path.write_text(json.dumps({"host": "0.0.0.0", "models": []}),
+ encoding="utf-8")
+ self.assertEqual(make_server.load_server_config(path),
+ {"host": "0.0.0.0", "models": []})
+
+ def test_missing_file_returns_none(self):
+ self.assertIsNone(make_server.load_server_config(
+ self.dir / "nope.json"))
+
+ def test_unreadable_json_returns_none(self):
+ path = self.dir / "server.json"
+ path.write_text("not json", encoding="utf-8")
+ self.assertIsNone(make_server.load_server_config(path))
+
+ def test_non_dict_document_returns_none(self):
+ path = self.dir / "server.json"
+ path.write_text("[1, 2, 3]", encoding="utf-8")
+ self.assertIsNone(make_server.load_server_config(path))
+
+
+class ServerConfigSelectionsTests(unittest.TestCase):
+ """server_config_selections: map server.json models back to the catalog."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.checkout = _make_checkout(Path(self._tmp.name))
+ self.catalog = make_server.load_model_catalog(self.checkout)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_maps_paths_to_family_dirs_and_tasks(self):
+ config = {"models": [
+ {"id": "qwen", "family": "qwen3_tts",
+ "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF", "task": "tts"},
+ {"id": "qwen-design", "family": "qwen3_tts",
+ "path": "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF",
+ "task": "vdes"},
+ {"id": "higgs", "family": "higgs_audio_tts",
+ "path": "models/Higgs-Audio-v3-TTS-4B-GGUF", "task": "tts"},
+ ]}
+ selected, tasks = make_server.server_config_selections(config,
+ self.catalog)
+ self.assertEqual(selected["qwen3_tts"],
+ ["Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"])
+ self.assertEqual(selected["higgs_audio_tts"],
+ ["Higgs-Audio-v3-TTS-4B-GGUF"])
+ self.assertEqual(tasks[("qwen3_tts",
+ "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF")],
+ "vdes")
+ self.assertEqual(tasks[("qwen3_tts",
+ "Qwen3-TTS-12Hz-1.7B-Base-GGUF")], "tts")
+
+ def test_unknown_family_ignored(self):
+ config = {"models": [
+ {"id": "x", "family": "not_a_family", "path": "models/x"},
+ ]}
+ selected, tasks = make_server.server_config_selections(config,
+ self.catalog)
+ self.assertEqual(selected, {})
+ self.assertEqual(tasks, {})
+
+ def test_absolute_and_unprefixed_paths_kept_as_targets(self):
+ config = {"models": [
+ {"id": "qwen", "family": "qwen3_tts",
+ "path": "/abs/Qwen3-TTS-12Hz-1.7B-Base-GGUF", "task": "tts"},
+ ]}
+ selected, tasks = make_server.server_config_selections(config,
+ self.catalog)
+ self.assertEqual(selected["qwen3_tts"],
+ ["/abs/Qwen3-TTS-12Hz-1.7B-Base-GGUF"])
+
+ def test_empty_models_yield_empty_selections(self):
+ selected, tasks = make_server.server_config_selections({"models": []},
+ self.catalog)
+ self.assertEqual(selected, {})
+ self.assertEqual(tasks, {})
+
+
+class UnusedInstalledEntriesTests(unittest.TestCase):
+ """unused_installed_entries: installed models dropped by a new selection."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ (self.dir / "models" / "kept").mkdir(parents=True)
+ (self.dir / "models" / "kept" / "m.gguf").write_bytes(b"x")
+ (self.dir / "models" / "dropped").mkdir()
+ (self.dir / "models" / "dropped" / "m.gguf").write_bytes(b"x")
+ (self.dir / "models" / "missing").mkdir() # empty: not installed
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _server_json(self, models):
+ path = self.dir / "server.json"
+ path.write_text(json.dumps({"models": models}), encoding="utf-8")
+ return path
+
+ def test_returns_installed_entries_not_in_new_paths(self):
+ path = self._server_json([
+ {"id": "kept", "path": "models/kept"},
+ {"id": "dropped", "path": "models/dropped"},
+ {"id": "missing", "path": "models/missing"},
+ ])
+ unused = make_server.unused_installed_entries(
+ path, {"models/kept"})
+ self.assertEqual([entry["id"] for entry in unused], ["dropped"])
+
+ def test_nothing_unused_when_all_kept(self):
+ path = self._server_json([
+ {"id": "kept", "path": "models/kept"},
+ ])
+ unused = make_server.unused_installed_entries(
+ path, {"models/kept"})
+ self.assertEqual(unused, [])
+
+
+class DeleteModelFilesTests(unittest.TestCase):
+ """delete_model_files: remove on-disk model files for {id, rel} entries."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ (self.dir / "models" / "a").mkdir(parents=True)
+ (self.dir / "models" / "a" / "m.gguf").write_bytes(b"x")
+ (self.dir / "models" / "b").mkdir()
+ (self.dir / "models" / "b" / "m.gguf").write_bytes(b"x")
+ (self.dir / "models" / "c").mkdir(parents=True)
+ self.server_json = self.dir / "server.json"
+ self.server_json.write_text(json.dumps({
+ "models": [
+ {"id": "a", "path": "models/a"},
+ {"id": "b", "path": "models/b"},
+ {"id": "c", "path": "models/c"},
+ ],
+ }), encoding="utf-8")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_removes_dirs_and_counts(self):
+ removed = make_server.delete_model_files(
+ self.server_json,
+ [{"id": "a", "rel": "models/a"}, {"id": "b", "rel": "models/b"}])
+ self.assertEqual(removed, 2)
+ self.assertFalse((self.dir / "models" / "a").exists())
+ self.assertFalse((self.dir / "models" / "b").exists())
+ self.assertTrue((self.dir / "models" / "c").exists())
+
+ def test_missing_paths_ignored(self):
+ removed = make_server.delete_model_files(
+ self.server_json, [{"id": "ghost", "rel": "models/ghost"}])
+ self.assertEqual(removed, 0)
+
+ def test_removes_single_file(self):
+ file_path = self.dir / "models" / "single.gguf"
+ file_path.write_bytes(b"x")
+ removed = make_server.delete_model_files(
+ self.server_json, [{"id": "s", "rel": "models/single.gguf"}])
+ self.assertEqual(removed, 1)
+ self.assertFalse(file_path.exists())
+
+ def test_absolute_rel_path_honored(self):
+ target = self.dir / "absolute"
+ target.mkdir()
+ (target / "m.gguf").write_bytes(b"x")
+ removed = make_server.delete_model_files(
+ self.server_json, [{"id": "a", "rel": str(target)}])
+ self.assertEqual(removed, 1)
+ self.assertFalse(target.exists())
+
+
+class InstallModelsTests(unittest.TestCase):
+ """install_models: runs the install helper with download=True."""
+
+ def test_downloads_delegating_to_install_models(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ guidance = [("qwen", "qwen3_tts_0_6b_base_q8_0")]
+ with patch.object(make_server, "_install_models") as mk:
+ make_server.install_models(checkout, guidance)
+ mk.assert_called_once_with(checkout, guidance, download=True)
+
+
+class HandInstallGuidanceTests(unittest.TestCase):
+ """hand_install_guidance: explains how to install models by hand."""
+
+ def test_lists_each_model_and_its_path(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ message = make_server.hand_install_guidance(checkout, [
+ {"id": "qwen", "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"},
+ {"id": "higgs", "rel": "models/Higgs-Audio-4B-GGUF"},
+ ])
+ self.assertIn("qwen", message)
+ self.assertIn("models/Qwen3-TTS-12Hz-0.6B-Base-GGUF", message)
+ self.assertIn("higgs", message)
+ self.assertIn("models/Higgs-Audio-4B-GGUF", message)
+ self.assertIn("download", message.lower())
+
+
+class UninstallTests(unittest.TestCase):
+ """uninstall: stop the server and remove the checkout."""
+
+ def test_removes_checkout(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "audio.cpp"
+ checkout.mkdir()
+ with patch.object(make_server, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(make_server.servers, "stop") as mk_stop:
+ rc = make_server.uninstall()
+ self.assertEqual(rc, 0)
+ self.assertFalse(checkout.exists())
+ mk_stop.assert_called_once_with("audiocpp")
+
+ def test_no_checkout_is_a_noop(self):
+ with patch.object(make_server, "find_local_checkout",
+ return_value=None), \
+ patch.object(make_server.servers, "stop") as mk_stop:
+ rc = make_server.uninstall()
+ self.assertEqual(rc, 0)
+ mk_stop.assert_called_once_with("audiocpp")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py
index 641f6ee..21baea7 100644
--- a/app/tests/test_backends_faster.py
+++ b/app/tests/test_backends_faster.py
@@ -80,6 +80,83 @@ class BuildVoicesTests(unittest.TestCase):
self.assertEqual(mock_transcribe.call_args.kwargs["model_name"], "large-v3")
+class LoadVoicesTests(unittest.TestCase):
+ """load_voices: read voices.json, or {} when unusable."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ self.path = self.dir / "voices.json"
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_reads_dict_document(self):
+ self.path.write_text(json.dumps({"narrator": {"ref_text": "hi"}}),
+ encoding="utf-8")
+ self.assertEqual(make_voices.load_voices(self.path),
+ {"narrator": {"ref_text": "hi"}})
+
+ def test_missing_file_returns_empty(self):
+ self.assertEqual(make_voices.load_voices(self.path), {})
+
+ def test_unreadable_json_returns_empty(self):
+ self.path.write_text("not json", encoding="utf-8")
+ self.assertEqual(make_voices.load_voices(self.path), {})
+
+ def test_non_dict_document_returns_empty(self):
+ self.path.write_text("[1, 2]", encoding="utf-8")
+ self.assertEqual(make_voices.load_voices(self.path), {})
+
+
+class DecideFasterTranscriptionTests(unittest.TestCase):
+ """_decide_faster_transcription: the re-transcribe plan questions."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+ self.narrator = self.folder / "narrator.wav"
+ self.narrator.write_bytes(b"x")
+ self.new_voice = self.folder / "new.wav"
+ self.new_voice.write_bytes(b"x")
+
+ def tearDown(self):
+ 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(
+ [self.narrator, self.new_voice],
+ {"narrator": {"ref_text": "old"}}, confirm)
+ self.assertEqual(plan["mode"], "missing")
+ self.assertEqual([w.name for w in plan["missing"]], ["new.wav"])
+
+ def test_declining_new_voices_transcribes_all(self):
+ confirm = lambda q, default=True: False # noqa: E731
+ plan = make_voices._decide_faster_transcription(
+ [self.narrator, self.new_voice],
+ {"narrator": {"ref_text": "old"}}, confirm)
+ 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)
+ 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)
+
+
class MainTests(unittest.TestCase):
"""The flag-only (non-TUI) path through main(), end to end."""
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 5f91a61..a8e3ac1 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -129,16 +129,16 @@ class HubMenuTests(unittest.TestCase):
return BackendStatus(key, label, installed=False, configured=False)
def test_quit_returns_none_when_no_backend(self):
- # No backends installed/running: menu is [Set up, Settings, Quit].
- # Quit is the 3rd option (Down twice) then Enter.
+ # No backends installed/running: menu is [Configure backends,
+ # Settings, Quit]. Quit is the 3rd option (Down twice) then Enter.
screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10])
with patch.object(hub, "detect_all", return_value=[]):
result = hub._hub_menu(screen)
self.assertIsNone(result)
- def test_menu_has_only_setup_settings_and_quit_without_backends(self):
+ def test_menu_has_only_configure_settings_and_quit_without_backends(self):
# Capture the options handed to tui.menu: with nothing installed or
- # running, Convert/Configure must be absent.
+ # running, Convert/Server must be absent.
captured = {}
def fake_menu(stdscr, title, options, **kwargs):
@@ -150,9 +150,9 @@ class HubMenuTests(unittest.TestCase):
patch.object(hub, "detect_all", return_value=[]):
hub._hub_menu(screen)
labels = [label for label, _ in captured["options"]]
- self.assertEqual(labels, ["Set up a backend", "Settings", "Quit"])
+ self.assertEqual(labels, ["Configure backends", "Settings", "Quit"])
- def test_menu_has_all_six_when_one_installed(self):
+ def test_menu_has_all_five_when_one_installed(self):
captured = {}
def fake_menu(stdscr, title, options, **kwargs):
@@ -169,9 +169,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Convert books", "Set up a backend",
- "Configure a backend", "Start/Stop Backend Servers",
- "Settings", "Quit"])
+ ["Convert books", "Configure backends",
+ "Start/Stop Backend Servers", "Settings", "Quit"])
# The status table is passed through, one row per backend.
self.assertEqual(captured["rows"],
[("qwen-tts", "installed", "warn", "body")])
@@ -199,9 +198,9 @@ class HubMenuTests(unittest.TestCase):
[("audio.cpp", "unavailable", "err", "dim"),
("qwen-tts", "running [remote]", "ok", "body")])
- def test_menu_hides_configure_and_server_when_only_running(self):
+ def test_menu_hides_server_when_only_running(self):
# Running but not installed (an external server) still unlocks
- # Convert — but Configure/Server need the backend on this machine.
+ # Convert — but Start/Stop needs the backend on this machine.
captured = {}
def fake_menu(stdscr, title, options, **kwargs):
@@ -217,7 +216,7 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Convert books", "Set up a backend", "Settings", "Quit"])
+ ["Convert books", "Configure backends", "Settings", "Quit"])
def test_ffmpeg_warning_shown_when_missing(self):
# ffmpeg not on PATH → a red notice is passed above the table.
@@ -252,9 +251,9 @@ class HubMenuTests(unittest.TestCase):
def test_convert_with_no_available_backend_flashes(self):
# Installed-but-not-ready backends → Convert is offered, but the
- # convert flow has nothing to list: it flashes a hint (no "Set up
- # a backend" detour anymore) and returns to the main menu. Then
- # quit: 6 main-menu options, Quit is the 6th (Down x5).
+ # convert flow has nothing to list: it flashes a hint (no "Configure
+ # backends" detour anymore) and returns to the main menu. Then
+ # quit: 5 main-menu options, Quit is the 5th (Down x4).
from backends import BackendStatus
statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
configured=False),
@@ -269,11 +268,11 @@ class HubMenuTests(unittest.TestCase):
with patch.object(hub, "detect_all", return_value=statuses), \
patch.object(hub.tui, "flash", fake_flash):
- # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter.
+ # Convert(Enter) → flash → main menu; Down x4 -> Quit, Enter.
screen = FakeScreen(keys=[10,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10])
+ 10])
result = hub._hub_menu(screen)
self.assertIsNone(result)
self.assertEqual(len(flashed), 1)
@@ -283,8 +282,8 @@ class HubMenuTests(unittest.TestCase):
class SubmenuStatusTableTests(unittest.TestCase):
"""First picker screen of every flow repeats the backend status table.
- Entries themselves stay clean: setup lists bare labels, and the
- Start/Stop menu offers only installed backends.
+ Entries themselves stay clean: the configure-backends menu lists flat
+ actions, and the Start/Stop menu offers only installed backends.
"""
def _capture_menu(self, captured):
@@ -305,14 +304,15 @@ class SubmenuStatusTableTests(unittest.TestCase):
return fake_form
- def test_setup_menu_lists_bare_labels_and_status_table(self):
+ def test_configure_backends_menu_lists_actions_and_status_table(self):
captured = {}
- infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0),
- BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0),
+ BackendInfo("faster", "faster-qwen3-tts", lambda: None,
+ lambda: 0)]
statuses = [
- BackendStatus("audiocpp", "audio.cpp", installed=True,
+ BackendStatus("qwen", "qwen-tts", installed=True,
configured=True),
- BackendStatus("qwen", "qwen-tts", installed=False,
+ BackendStatus("faster", "faster-qwen3-tts", installed=False,
configured=False, running=True, remote=True),
]
with patch.object(hub, "REGISTRY", infos), \
@@ -320,19 +320,66 @@ class SubmenuStatusTableTests(unittest.TestCase):
self._capture_menu(captured)), \
patch.object(hub.shutil, "which",
return_value="/usr/bin/ffmpeg"):
- result = hub._setup_menu(None, statuses)
+ result = hub._configure_backends_menu(None, statuses)
self.assertIsNone(result)
- # No inline "(running)"-style suffix on the entries anymore...
+ # Install (faster uninstalled), Configure (qwen installed), then
+ # Uninstall (qwen installed); no audio.cpp means no model actions.
self.assertEqual([label for label, _ in captured["options"]],
- ["audio.cpp", "qwen-tts"])
+ ["Install Backend", "Configure qwen-tts",
+ "Uninstall Backend"])
# ...the shared status table carries the states instead.
self.assertEqual(captured["table_title"], "Backend status")
self.assertEqual(
captured["table_rows"],
- [("audio.cpp", "installed", "warn", "body"),
- ("qwen-tts", "running [remote]", "ok", "body")])
+ [("qwen-tts", "installed", "warn", "body"),
+ ("faster-qwen3-tts", "running [remote]", "ok", "body")])
self.assertIsNone(captured["notice_lines"])
+ def test_configure_backends_menu_install_only_when_nothing_installed(self):
+ captured = {}
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=False,
+ configured=False)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._configure_backends_menu(None, statuses)
+ self.assertIsNone(result)
+ # Nothing installed: only the install entry is offered.
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["Install Backend"])
+
+ def test_configure_backends_menu_audiocpp_model_actions(self):
+ captured = {}
+ infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)]
+ statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True)]
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ (checkout / "server.json").write_text(json.dumps({
+ "models": [{"id": "present", "path": "models/present"},
+ {"id": "absent", "path": "models/absent"}],
+ }), encoding="utf-8")
+ (checkout / "models" / "present").mkdir(parents=True)
+ (checkout / "models" / "present" / "m.gguf").write_bytes(b"x")
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu",
+ self._capture_menu(captured)), \
+ patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ result = hub._configure_backends_menu(None, statuses)
+ self.assertIsNone(result)
+ labels = [label for label, _ in captured["options"]]
+ # A model is missing (download), plus the installed backend's
+ # configure + uninstall entries. Deleting unused models now lives
+ # inside the "Configure audio.cpp" wizard, not here.
+ self.assertEqual(
+ labels,
+ ["Configure audio.cpp", "Download Missing Models (audio.cpp)",
+ "Uninstall Backend"])
+
def test_convert_menu_builds_one_form_with_backend_field(self):
captured = {}
st = BackendStatus("qwen", "qwen-tts", installed=True,
@@ -373,7 +420,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
self.assertEqual(menus, [])
self.assertIn("No backend is ready", flashed[0])
- def test_configure_menu_shows_status_table(self):
+ def test_configure_backends_menu_shows_status_table(self):
captured = {}
infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
@@ -382,7 +429,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.tui, "menu",
self._capture_menu(captured)), \
patch.object(hub.shutil, "which", return_value="/x"):
- result = hub._configure_menu(None, statuses)
+ result = hub._configure_backends_menu(None, statuses)
self.assertIsNone(result)
self.assertEqual(captured["table_title"], "Backend status")
self.assertEqual(
@@ -433,7 +480,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.tui, "menu",
self._capture_menu(captured)), \
patch.object(hub.shutil, "which", return_value=None):
- hub._setup_menu(None, statuses)
+ hub._configure_backends_menu(None, statuses)
self.assertEqual(captured["notice_lines"],
[("Warning: ffmpeg not installed!", "err")])
@@ -1408,5 +1455,164 @@ class AudiocppServerConfigTests(unittest.TestCase):
self.assertFalse(audiocpp_backend.update_server_config_port(9090))
+class ConfigureBackendsDispatchTests(unittest.TestCase):
+ """run() and the configure-backends submenus dispatch their commands."""
+
+ def test_run_dispatches_install_to_setup_tui(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+
+ def fake_wrapper(cb):
+ fake_wrapper.calls += 1
+ return ("install", "qwen") if fake_wrapper.calls == 1 else None
+ fake_wrapper.calls = 0
+
+ import curses
+ with patch.object(curses, "wrapper", fake_wrapper), \
+ patch.object(hub, "get", return_value=info), \
+ patch.object(info, "setup_tui") as mk_setup:
+ hub.run()
+ mk_setup.assert_called_once_with()
+
+ def test_run_dispatches_uninstall(self):
+ info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)
+
+ def fake_wrapper(cb):
+ fake_wrapper.calls += 1
+ return ("uninstall", "qwen") if fake_wrapper.calls == 1 else None
+ fake_wrapper.calls = 0
+
+ import curses
+ with patch.object(curses, "wrapper", fake_wrapper), \
+ patch.object(hub, "get", return_value=info), \
+ patch.object(info, "uninstall") as mk_uninstall:
+ hub.run()
+ mk_uninstall.assert_called_once_with()
+
+ def _capture_flashes(self):
+ flashes = []
+
+ def fake_flash(stdscr, text, kind="warn"):
+ flashes.append((text, kind))
+
+ return patch.object(hub.tui, "flash", fake_flash), flashes
+
+ def test_download_models_action_flashes_hand_install_guidance(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ (checkout / "server.json").write_text(json.dumps({"models": []}),
+ encoding="utf-8")
+ missing = [{"id": "qwen",
+ "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"}]
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(hub.audiocpp_backend, "missing_model_entries",
+ return_value=missing), \
+ patch.object(hub.audiocpp_backend,
+ "missing_model_install_guidance",
+ return_value=[]), \
+ patch.object(hub.audiocpp_backend, "hand_install_guidance",
+ return_value="do it by hand") as mk_hand, \
+ patch_flash:
+ hub._download_models_action(None)
+ self.assertEqual(flashes, [("do it by hand", "err")])
+ mk_hand.assert_called_once()
+
+ def test_download_models_action_flashes_ok_when_nothing_missing(self):
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ (checkout / "server.json").write_text(json.dumps({"models": []}),
+ encoding="utf-8")
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(hub.audiocpp_backend, "missing_model_entries",
+ return_value=[]), \
+ patch_flash:
+ hub._download_models_action(None)
+ self.assertEqual(len(flashes), 1)
+ self.assertEqual(flashes[0][1], "ok")
+
+ def test_download_models_action_suspends_and_installs(self):
+ import contextlib
+
+ @contextlib.contextmanager
+ def fake_suspend(scr):
+ yield
+
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td)
+ (checkout / "server.json").write_text(json.dumps({"models": []}),
+ encoding="utf-8")
+ missing = [{"id": "qwen", "rel": "models/q"}]
+ guidance = [("qwen", "qwen3_tts_0_6b_base_q8_0")]
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(hub.audiocpp_backend, "missing_model_entries",
+ return_value=missing), \
+ patch.object(hub.audiocpp_backend,
+ "missing_model_install_guidance",
+ return_value=guidance), \
+ patch.object(hub.tui, "suspend", fake_suspend), \
+ patch.object(hub.audiocpp_backend, "install_models") as mk, \
+ patch_flash:
+ hub._download_models_action(None)
+ mk.assert_called_once_with(checkout, guidance)
+ self.assertEqual(len(flashes), 1)
+ self.assertEqual(flashes[0][1], "ok")
+
+ def test_download_models_action_flashes_error_when_no_checkout(self):
+ patch_flash, flashes = self._capture_flashes()
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=None), patch_flash:
+ hub._download_models_action(None)
+ self.assertEqual(len(flashes), 1)
+ self.assertEqual(flashes[0][1], "err")
+
+ def test_pick_backend_menu_install_lists_uninstalled_only(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return hub._GO_BACK
+
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0),
+ BackendInfo("faster", "faster-qwen3-tts", lambda: None,
+ lambda: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True),
+ BackendStatus("faster", "faster-qwen3-tts",
+ installed=False, configured=False)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu", fake_menu):
+ result = hub._pick_backend_menu(None, statuses, "Install Backend",
+ installed_only=False)
+ self.assertIsNone(result)
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["faster-qwen3-tts"])
+
+ def test_pick_backend_menu_uninstall_lists_installed_only(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return "qwen"
+
+ infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0),
+ BackendInfo("faster", "faster-qwen3-tts", lambda: None,
+ lambda: 0)]
+ statuses = [BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True),
+ BackendStatus("faster", "faster-qwen3-tts",
+ installed=False, configured=False)]
+ with patch.object(hub, "REGISTRY", infos), \
+ patch.object(hub.tui, "menu", fake_menu):
+ result = hub._pick_backend_menu(None, statuses, "Uninstall Backend",
+ installed_only=True)
+ self.assertEqual(result, ("uninstall", "qwen"))
+ self.assertEqual([label for label, _ in captured["options"]],
+ ["qwen-tts"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index ce408af..49960a1 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -857,6 +857,37 @@ class CheckboxTreeTests(TuiTestCase):
picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
self.assertEqual(picked, [(0, "pkg-a")])
+ def test_prechecked_selection_accepted_directly(self):
+ # checked= seeds the tree (modify flow): Enter alone accepts the
+ # pre-checked option without any key presses in between.
+ screen = FakeScreen(keys=[10])
+ picked = tui.checkbox_tree(
+ screen, "Pick models", self.FAMILIES,
+ checked={(0, "pkg-b"), (1, "pkg-c")})
+ self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")])
+
+ def test_prechecked_options_draw_as_checked(self):
+ screen = FakeScreen(keys=[10])
+ tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
+ checked={(0, "pkg-b")})
+ texts = [text for _, _, text, _ in screen.strings]
+ # The pre-checked option row (indented) draws its box checked.
+ self.assertIn("[x] ", texts)
+ # ...and the family row is expanded (its options are listed).
+ self.assertIn("- Family one", texts)
+
+ def test_prechecked_family_cursor_starts_on_it(self):
+ # Only the second family is pre-checked, so the cursor starts on it:
+ # Space clears then re-checks that family (the cursor never moves).
+ # If the cursor were still on the first family, the two Spaces would
+ # check then clear family one and Enter would flash instead of
+ # accepting anything.
+ screen = FakeScreen(keys=[ord(" "), ord(" "), 10])
+ picked = tui.checkbox_tree(
+ screen, "Pick models", self.FAMILIES,
+ checked={(1, "pkg-c")})
+ self.assertEqual(picked, [(1, "pkg-c")])
+
def test_empty_families_rejected(self):
with self.assertRaises(ValueError):
tui.checkbox_tree(self.screen, "Pick", [])