From 0522e73b68291af62e43c387ab8f9b8ffa2cab47 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 18:57:09 -0400 Subject: feat: configure [backend] loads existing backend config rather than just overwriting it --- app/tests/test_hub.py | 270 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 238 insertions(+), 32 deletions(-) (limited to 'app/tests/test_hub.py') 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() -- cgit v1.2.3