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.py242
1 files changed, 227 insertions, 15 deletions
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."""