aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 18:57:09 -0400
committerhistoria <historiavg@proton.me>2026-08-24 18:57:09 -0400
commit0522e73b68291af62e43c387ab8f9b8ffa2cab47 (patch)
tree1800168c1ed5de7540624265945faba8251a7cab /app/tests/test_hub.py
parentd950fc8e64ee508334e608f6045d687d73a464be (diff)
downloadtts-audiobook-generator-0522e73b68291af62e43c387ab8f9b8ffa2cab47.tar.gz
feat: configure [backend] loads existing backend config rather than just overwriting it
Diffstat (limited to 'app/tests/test_hub.py')
-rw-r--r--app/tests/test_hub.py270
1 files changed, 238 insertions, 32 deletions
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()