From fe4b2b9eb7fb8aac81f65630720c9079d0a3121a Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 23:49:34 -0400 Subject: feat: user-friendly menu gating, clearer install/configure path for backends --- app/tests/test_hub.py | 242 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 227 insertions(+), 15 deletions(-) (limited to 'app/tests/test_hub.py') diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index d6340da..68d0f34 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -4,6 +4,8 @@ The hub drives the same curses widgets as ui/tui.py, so these tests reuse the fake curses/screen from test_tui to run the menu without a terminal. """ +import contextlib +import io import json import tempfile import unittest @@ -90,6 +92,12 @@ class HubHelperTests(unittest.TestCase): installed = BackendStatus("k", "l", installed=True, configured=False) none = BackendStatus("k", "l", installed=False, configured=False) + downloaded = BackendStatus("k", "l", installed=False, + configured=False, + partial="downloaded (not built)") + built_unconfigured = BackendStatus("k", "l", installed=True, + configured=False, + partial="built (not configured)") # running beats installed (a server is up even if not configured); # only a backend that is neither installed nor running is dimmed. self.assertEqual(hub._status_mark(local), @@ -110,6 +118,12 @@ class HubHelperTests(unittest.TestCase): ("unavailable", "err", "dim")) self.assertEqual(hub._status_mark(None), ("unavailable", "err", "dim")) + # Part-way states: amber text; the name is dimmed while the backend + # is still unusable (not installed), bright once it is built. + self.assertEqual(hub._status_mark(downloaded), + ("downloaded (not built)", "warn", "dim")) + self.assertEqual(hub._status_mark(built_unconfigured), + ("built (not configured)", "warn", "body")) class HubMenuTests(unittest.TestCase): @@ -136,6 +150,32 @@ class HubMenuTests(unittest.TestCase): result = hub._Hub(screen).run() self.assertIsNone(result) + def test_run_prints_post_tui_notices_after_session(self): + # The TUI runs in curses, so setup steps queue notices for the + # console; hub.run must print them once the session ends. + def fake_app(stdscr): + hub.common.record_post_tui_notice( + "[ERROR] audio.cpp build failed (exit code 2).\n" + " Build log: /tmp/audiocpp_build_20260101_000000.log") + hub.common.record_post_tui_notice("second notice") + + def fake_wrapper(func, *args, **kwargs): + func(None) + return 0 + + buffer = io.StringIO() + self.curses.wrapper = fake_wrapper + with patch.object(hub, "_app", fake_app), \ + contextlib.redirect_stdout(buffer): + rc = hub.run() + self.assertEqual(rc, 0) + out = buffer.getvalue() + self.assertIn("[ERROR] audio.cpp build failed (exit code 2).", out) + self.assertIn("Build log: /tmp/audiocpp_build_20260101_000000.log", + out) + self.assertIn("second notice", out) + self.assertEqual(hub.common.drain_post_tui_notices(), []) + 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/Server must be absent. @@ -295,6 +335,10 @@ class SubmenuStatusTableTests(unittest.TestCase): return fake_menu + def _labels(self, options): + """Option labels, skipping MENU_SEPARATOR divider rows.""" + return [opt[0] for opt in options if opt is not tui.MENU_SEPARATOR] + def _capture_form(self, captured): def fake_form(stdscr, title, fields, **kwargs): captured["title"] = title @@ -365,6 +409,9 @@ class SubmenuStatusTableTests(unittest.TestCase): }), encoding="utf-8") (checkout / "models" / "present").mkdir(parents=True) (checkout / "models" / "present" / "m.gguf").write_bytes(b"x") + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") with patch.object(hub, "REGISTRY", infos), \ patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub.tui, "menu", @@ -374,14 +421,102 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.shutil, "which", return_value="/x"): result = hub._Hub(None).screen_configure() self.assertIs(result, tui.Wizard.BACK) - 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. + labels = self._labels(captured["options"]) + # The missing-model download heads the menu as the recommended next + # step (yellow suffix), separated from the rest by a blank line; + # Configure + Uninstall follow. The backend is built, so no "Build" + # action is offered. Deleting unused models now lives inside the + # "Configure audio.cpp" wizard, not here. self.assertEqual( labels, - ["Configure audio.cpp", "Download Missing Models (audio.cpp)", + ["Download Missing Models (audio.cpp)", "Configure audio.cpp", "Uninstall Backend"]) + self.assertEqual(captured["options"][0], + ("Download Missing Models (audio.cpp)", + "download_models", ("[recommended]", "warn"))) + self.assertIs(captured["options"][1], tui.MENU_SEPARATOR) + + def test_configure_backends_menu_offers_build_when_not_built(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + # installed=False (not built), but configured (server.json exists). + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=True)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({ + "models": [{"id": "absent", "path": "models/absent"}], + }), encoding="utf-8") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + 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._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + labels = self._labels(captured["options"]) + # Not built → the Build action heads the menu as the recommended + # next step (yellow suffix, blank separator below); Uninstall follows + # (a downloaded checkout is removable). A downloaded-but-unbuilt + # checkout is NOT installable, so no "Install Backend" entry, and the + # model download stays hidden until the binary exists — Build and + # Download never coexist. Configure needs an installed (built) + # backend. + self.assertEqual(labels, ["Build audio.cpp server", "Uninstall Backend"]) + self.assertEqual(captured["options"][0], + ("Build audio.cpp server", "build_audiocpp", + ("[recommended]", "warn"))) + self.assertIs(captured["options"][1], tui.MENU_SEPARATOR) + + def test_configure_backends_menu_omits_build_when_built(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": []}), + encoding="utf-8") + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + 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._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + labels = self._labels(captured["options"]) + self.assertNotIn("Build audio.cpp server", labels) + + def test_configure_backends_menu_configure_only_when_built_unconfigured(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + # Built but no server.json: only Configure (the next step) plus + # Uninstall — no Build, no Download, no Install entry. + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=False)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + 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._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(self._labels(captured["options"]), + ["Configure audio.cpp", "Uninstall Backend"]) def test_convert_menu_builds_one_form_with_backend_field(self): captured = {} @@ -1591,13 +1726,7 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): 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 - + def test_download_models_action_runs_in_task_view_and_installs(self): with tempfile.TemporaryDirectory() as td: checkout = Path(td) (checkout / "server.json").write_text(json.dumps({"models": []}), @@ -1612,11 +1741,24 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): 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.object(hub.taskview, "run_steps", + return_value=0) as mk_run, \ + patch.object(hub.audiocpp_backend, + "install_models") as mk_install, \ patch_flash: hub._download_models_action(None) - mk.assert_called_once_with(checkout, guidance) + # The downloads run in the TUI task view (one step), not via + # suspend; executing the step forwards emit/cancel to + # install_models. + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][0], None) + steps = mk_run.call_args[0][2] + self.assertEqual([step.title for step in steps], + ["Download missing models"]) + emit = lambda line: None + steps[0].work(emit, None) + mk_install.assert_called_once_with( + checkout, guidance, emit=emit, cancel=None) self.assertEqual(len(flashes), 1) self.assertEqual(flashes[0][1], "ok") @@ -1650,6 +1792,53 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["faster-qwen3-tts"]) + def test_pick_backend_install_skips_downloaded_not_built_audiocpp(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0), + BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False), + BackendStatus("qwen", "qwen-tts", installed=False, + configured=False)] + # audio.cpp has a checkout (downloaded but not built): its next step + # is the Build action, so it must not reappear in the Install picker. + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=Path("/tmp/audiocpp")): + result = hub._Hub(None)._pick_backend(installed_only=False) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["qwen-tts"]) + + def test_pick_backend_install_lists_audiocpp_without_checkout(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None): + result = hub._Hub(None)._pick_backend(installed_only=False) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp"]) + def test_pick_backend_uninstall_lists_installed_only(self): captured = {} @@ -1674,6 +1863,29 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["qwen-tts"]) + def test_pick_backend_uninstall_lists_downloaded_not_built_audiocpp(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0)] + # Downloaded but not built (installed=False): still removable, so the + # uninstall picker must list it (its checkout lives on disk). + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=Path("/tmp/audiocpp")): + result = hub._Hub(None)._pick_backend(installed_only=True) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp"]) + class HubNavigationTests(unittest.TestCase): """Esc (and q) steps back exactly one screen across the whole hub.""" -- cgit v1.2.3