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_backends.py | 20 +-- app/tests/test_backends_audiocpp.py | 219 ++++++++++++++++++++++++++++++-- app/tests/test_backends_common.py | 76 +++++++++++ app/tests/test_backends_envs.py | 2 +- app/tests/test_backends_faster.py | 24 ++-- app/tests/test_hub.py | 242 +++++++++++++++++++++++++++++++++--- app/tests/test_taskview.py | 231 ++++++++++++++++++++++++++++++++++ app/tests/test_tui.py | 27 ++++ 8 files changed, 795 insertions(+), 46 deletions(-) create mode 100644 app/tests/test_backends_common.py create mode 100644 app/tests/test_taskview.py (limited to 'app/tests') diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 9ecedd1..0d3be37 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -304,20 +304,24 @@ class QwenSetupScreenTests(unittest.TestCase): def test_abort_returns_one_without_executing(self): from backends import qwen with patch.object(qwen, "_wizard", return_value=None) as mk_wizard, \ - patch.object(qwen, "_execute") as mk_execute: + patch.object(qwen, "_execute_steps") as mk_steps: rc = qwen.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): - import contextlib + def test_success_runs_the_tail_in_the_task_view(self): from backends import qwen settings = {"custom_port": 7860} + steps = [qwen.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(qwen, "_wizard", return_value=settings), \ - patch.object(qwen, "_execute", return_value=0) as mk_execute, \ - patch.object(qwen.tui, "suspend", contextlib.nullcontext): + patch.object(qwen, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(qwen.taskview, "run_steps", + return_value=0) as mk_run: rc = qwen.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index b724c0d..8f79085 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -5,6 +5,7 @@ import io import json import sys import tempfile +import threading import unittest from contextlib import redirect_stdout from pathlib import Path @@ -792,6 +793,46 @@ class FindAudiocppServerBinTests(unittest.TestCase): self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout)) +class BuiltServerBinaryTests(unittest.TestCase): + """built_server_binary: locating a specific backend's build.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + + def tearDown(self): + self._td.cleanup() + + def _build(self, name, binary="audiocpp_server"): + bin_dir = self.checkout / "build" / name / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / binary).write_bytes(b"x") + + def test_returns_the_matching_backend_binary(self): + self._build("linux-cuda-release") + self._build("linux-cpu-release") + self.assertEqual( + make_server.built_server_binary(self.checkout, "cpu"), + self.checkout / "build" / "linux-cpu-release" / "bin" + / "audiocpp_server") + + def test_returns_none_for_unbuilt_backend(self): + self._build("linux-cuda-release") + self.assertIsNone( + make_server.built_server_binary(self.checkout, "vulkan")) + + def test_metal_counts_as_cpu(self): + self._build("macos-metal-release") + self.assertEqual( + make_server.built_server_binary(self.checkout, "cpu"), + self.checkout / "build" / "macos-metal-release" / "bin" + / "audiocpp_server") + + def test_no_build_dir_returns_none(self): + self.assertIsNone(make_server.built_server_binary(self.checkout, "cpu")) + + class BuildAudiocppTests(unittest.TestCase): """Running the audio.cpp build helper script.""" @@ -803,10 +844,23 @@ class BuildAudiocppTests(unittest.TestCase): self.scripts.mkdir() (self.scripts / "build_linux.sh").write_text("#!/bin/sh\n", encoding="utf-8") + self.log_dir = Path(self._td.name) / "logs" + self.addCleanup(make_server.common.drain_post_tui_notices) def tearDown(self): self._td.cleanup() + def _emit(self): + lines = [] + + def emit(line): + lines.append(line) + + return lines, emit + + def _log_files(self): + return sorted(self.log_dir.glob("audiocpp_build_*.log")) + def test_runs_build_script_with_backend_and_target(self): with patch.object(make_server.common, "run_console_subprocess", return_value=0) as run: @@ -826,6 +880,81 @@ class BuildAudiocppTests(unittest.TestCase): rc = make_server.build_audiocpp(self.checkout, "cuda") self.assertNotEqual(rc, 0) + def test_console_path_writes_no_log_and_no_notice(self): + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=0): + rc = make_server.build_audiocpp(self.checkout, "cuda") + self.assertEqual(rc, 0) + self.assertEqual(self._log_files(), []) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_success_writes_log_and_no_notice(self): + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=0): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertEqual(rc, 0) + self.assertEqual(len(self._log_files()), 1) + log_text = self._log_files()[0].read_text(encoding="utf-8") + self.assertIn("[INFO] Building audiocpp_server", log_text) + self.assertIn("--backend cuda", log_text) + self.assertTrue(emitted) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_failure_writes_log_and_records_notice(self): + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=3): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertEqual(rc, 3) + logs = self._log_files() + self.assertEqual(len(logs), 1) + log_text = logs[0].read_text(encoding="utf-8") + self.assertIn("failed (exit code 3)", log_text) + notices = make_server.common.drain_post_tui_notices() + self.assertEqual(len(notices), 1) + notice = notices[0] + self.assertIn("failed (exit code 3)", notice) + self.assertIn(f"Build log: {logs[0]}", notice) + command = (f"cd {self.checkout} && sh " + f"{self.scripts / 'build_linux.sh'} --backend cuda " + "--target audiocpp_server") + self.assertIn(command, notice) + self.assertIn("Troubleshoot by re-running this command", notice) + self.assertTrue(any("failed (exit code 3)" in line + for line in emitted)) + + def test_tui_cancel_suppresses_notice_but_writes_log(self): + emitted, emit = self._emit() + cancel = threading.Event() + cancel.set() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=130): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit, cancel=cancel) + self.assertEqual(rc, 130) + self.assertEqual(len(self._log_files()), 1) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_missing_script_records_guidance_notice(self): + for f in self.scripts.iterdir(): + f.unlink() + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertNotEqual(rc, 0) + self.assertEqual(self._log_files(), []) + notices = make_server.common.drain_post_tui_notices() + self.assertEqual(len(notices), 1) + self.assertIn("No build script found", notices[0]) + class AudiocppDetectTests(unittest.TestCase): """backends.audiocpp.detect() status reporting.""" @@ -854,6 +983,7 @@ class AudiocppDetectTests(unittest.TestCase): self.assertFalse(status.installed) self.assertFalse(status.configured) self.assertEqual(status.launch_hint, "") + self.assertEqual(status.partial, "downloaded (not built)") def test_built_and_configured_ready(self): binary = self.checkout / "build" / "linux-cuda-release" / "bin" \ @@ -869,6 +999,19 @@ class AudiocppDetectTests(unittest.TestCase): self.assertTrue(status.configured) self.assertIn(str(binary), status.launch_hint) self.assertIn(str(server_json), status.launch_hint) + self.assertEqual(status.partial, "") + + def test_built_not_configured(self): + binary = self.checkout / "build" / "linux-cuda-release" / "bin" \ + / "audiocpp_server" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"x") + with patch.object(make_server, "find_local_checkout", + return_value=self.checkout): + status = make_server.detect() + self.assertTrue(status.installed) + self.assertFalse(status.configured) + self.assertEqual(status.partial, "built (not configured)") class NonInteractiveMainTests(unittest.TestCase): @@ -918,7 +1061,7 @@ class NonInteractiveMainTests(unittest.TestCase): self.assertEqual(data["host"], "127.0.0.1") self.assertEqual(data["port"], make_server.config_port()) self.assertEqual(data["backend"], "cuda") - self.assertFalse(data["lazy_load"]) + self.assertTrue(data["lazy_load"]) self.assertEqual([m["id"] for m in data["models"]], ["higgs"]) self.assertNotIn("voice_dir", data) @@ -1490,7 +1633,8 @@ class InstallModelsTests(unittest.TestCase): 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) + mk.assert_called_once_with(checkout, guidance, download=True, + emit=None, cancel=None) class HandInstallGuidanceTests(unittest.TestCase): @@ -1533,6 +1677,53 @@ class WizardNavigationTests(unittest.TestCase): make_server.build_parser()) self.assertIsNone(settings) + def test_modify_flow_offers_build_when_not_built(self): + # A server.json recording "vulkan" exists, but nothing is built: the + # wizard must still reach the backend menu (pre-selecting vulkan) and + # offer the build — instead of silently skipping it because the + # existing server.json already records a backend. + checkout = self._checkout() + (checkout / "server.json").write_text( + json.dumps({"models": [], "backend": "vulkan"}), + encoding="utf-8") + catalog = make_server.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + confirm_questions = [] + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_line_edit(stdscr, title, default, **kwargs): + if title == "Bind host": + return "127.0.0.1" + if title == "Port": + return "8080" + return default + + def fake_menu(stdscr, title, options, **kwargs): + return "vulkan" + + def fake_confirm(stdscr, question, **kwargs): + confirm_questions.append(question) + return False # decline the build + + with patch.object(make_server, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "line_edit", side_effect=fake_line_edit), \ + patch.object(tui, "menu", side_effect=fake_menu), \ + patch.object(tui, "confirm", side_effect=fake_confirm): + settings = make_server._wizard(None, self._args(), + make_server.build_parser()) + self.assertIsNotNone(settings) + self.assertEqual(settings["backend"], "vulkan") + self.assertFalse(settings["build"]) + # The build offer was shown (and declined); the old modify flow + # skipped it entirely. + self.assertTrue(any("not built for vulkan" in q + for q in confirm_questions)) + def test_bind_host_esc_returns_to_families_tree(self): # Esc on "Bind host" must fall back to the model-family tree, then # re-selecting proceeds through the rest of the wizard. @@ -1601,24 +1792,28 @@ if __name__ == "__main__": class SetupScreenTests(unittest.TestCase): - """setup_screen: the wizard run on the hub's screen, console tail via - suspend.""" + """setup_screen: the wizard run on the hub's screen, setup tail via the + in-TUI task view.""" def test_abort_returns_one_without_executing(self): with patch.object(make_server, "_wizard", return_value=None) as mk_wizard, \ - patch.object(make_server, "_execute") as mk_execute: + patch.object(make_server, "_execute_steps") as mk_steps: rc = make_server.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): + def test_success_runs_the_tail_in_the_task_view(self): settings = {"audiocpp_dir": Path("/x")} + steps = [make_server.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(make_server, "_wizard", return_value=settings), \ - patch.object(make_server, "_execute", - return_value=0) as mk_execute, \ - patch.object(tui, "suspend", contextlib.nullcontext): + patch.object(make_server, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(make_server.taskview, "run_steps", + return_value=0) as mk_run: rc = make_server.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py new file mode 100644 index 0000000..b8a8e90 --- /dev/null +++ b/app/tests/test_backends_common.py @@ -0,0 +1,76 @@ +"""Tests for backends.common subprocess/git helpers. + +The streaming mode of ``run_console_subprocess`` (used by the in-TUI task +view) is exercised with a real child process: output lines are captured and +forwarded, cancellation kills the child and returns 130, and an on_cancel +hook runs first. +""" + +import sys +import threading +import unittest +from unittest import mock + +from backends import common + + +class RunConsoleSubprocessStreamingTests(unittest.TestCase): + def test_streaming_emits_merged_lines(self): + lines = [] + rc = common.run_console_subprocess( + [sys.executable, "-c", + "import sys; sys.stdout.write('hello\\nworld\\n'); " + "sys.stderr.write('oops\\n')"], + emit=lines.append) + self.assertEqual(rc, 0) + # stdout/stderr are merged, in arrival order. + self.assertEqual(sorted(lines), ["hello", "oops", "world"]) + + def test_streaming_returns_the_exit_code(self): + rc = common.run_console_subprocess( + [sys.executable, "-c", "import sys; sys.exit(3)"], + emit=lambda line: None) + self.assertEqual(rc, 3) + + def test_cancel_kills_the_process_and_returns_130(self): + cancel = threading.Event() + cancel.set() + rc = common.run_console_subprocess( + [sys.executable, "-c", "import time; time.sleep(60)"], + emit=lambda line: None, cancel=cancel) + self.assertEqual(rc, 130) + + def test_on_cancel_hook_runs_before_kill(self): + cancel = threading.Event() + cancel.set() + touched = [] + common.run_console_subprocess( + [sys.executable, "-c", "import time; time.sleep(60)"], + emit=lambda line: None, cancel=cancel, + on_cancel=lambda: touched.append(True)) + self.assertEqual(touched, [True]) + + +class GitCloneTests(unittest.TestCase): + def test_git_clone_console_passes_through(self): + with mock.patch.object(common, "run_console_subprocess", + return_value=0) as run: + self.assertEqual(common.git_clone("url", common.Path("/t")), 0) + # Console mode: no --progress flag, plain git clone. + self.assertEqual(run.call_args[0][0], + ["git", "clone", "url", "/t"]) + + def test_git_clone_streaming_adds_progress(self): + emit = lambda line: None + with mock.patch.object(common, "run_console_subprocess", + return_value=0) as run: + self.assertEqual(common.git_clone("url", common.Path("/t"), + emit=emit), 0) + argv = run.call_args[0][0] + self.assertEqual(argv[:3], ["git", "clone", "--progress"]) + self.assertIn("url", argv) + self.assertEqual(run.call_args[1]["emit"], emit) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py index cf4ecc6..bf27f60 100644 --- a/app/tests/test_backends_envs.py +++ b/app/tests/test_backends_envs.py @@ -73,7 +73,7 @@ class PipInstallTests(unittest.TestCase): def test_creates_env_first_when_missing(self): calls = [] - def fake_run(argv): + def fake_run(argv, **kwargs): calls.append(list(argv)) return 0 diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 0da461a..eb1fa28 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -251,24 +251,28 @@ if __name__ == "__main__": class SetupScreenTests(unittest.TestCase): - """setup_screen: the wizard run on the hub's screen, console tail via - suspend.""" + """setup_screen: the wizard run on the hub's screen, setup tail via the + in-TUI task view.""" def test_abort_returns_one_without_executing(self): with patch.object(make_voices, "_wizard", return_value=None) as mk_wizard, \ - patch.object(make_voices, "_execute") as mk_execute: + patch.object(make_voices, "_execute_steps") as mk_steps: rc = make_voices.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): + def test_success_runs_the_tail_in_the_task_view(self): settings = {"wav_dir": Path("/x")} + steps = [make_voices.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(make_voices, "_wizard", return_value=settings), \ - patch.object(make_voices, "_execute", - return_value=0) as mk_execute, \ - patch.object(make_voices.tui, "suspend", contextlib.nullcontext): + patch.object(make_voices, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(make_voices.taskview, "run_steps", + return_value=0) as mk_run: rc = make_voices.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) 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.""" diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py new file mode 100644 index 0000000..15fc501 --- /dev/null +++ b/app/tests/test_taskview.py @@ -0,0 +1,231 @@ +"""Tests for the in-TUI task view (ui/taskview.py). + +The view is driven the same way as the other TUI widgets: the fake curses +module and recording screen from test_tui stand in for a terminal. The +step-sequencing logic is exercised through ``run_steps_inline`` (no thread), +the progress-line parsing through ``TaskView._ingest_line``, and state +transitions through ``handle_event`` + ``_step_mark`` + ``_result_rc``. +""" + +import sys +import unittest +from unittest.mock import patch + +from tests.test_tui import FakeCurses, FakeScreen +from ui import taskview + + +def _step(title, rc=0): + def work(emit, cancel): + return rc + return taskview.TaskStep(title, work) + + +class _FakeTui: + def setUp(self): + self.curses = FakeCurses() + patcher = patch.dict(sys.modules, {"curses": self.curses}) + patcher.start() + self.addCleanup(patcher.stop) + taskview.tui._THEME.clear() + self.addCleanup(taskview.tui._THEME.clear) + + def make_view(self, steps=(), width=80, height=24): + screen = FakeScreen(width=width, height=height) + with patch.object(taskview.TaskView, "_worker_main", lambda self: None): + view = taskview.TaskView(screen, "Setup", list(steps), + clock=lambda: 1000.0) + return view, screen + + +class RunStepsInlineTests(unittest.TestCase): + def test_runs_steps_in_order_and_returns_zero(self): + order = [] + steps = [ + taskview.TaskStep("a", lambda emit, cancel: order.append("a") or 0), + taskview.TaskStep("b", lambda emit, cancel: order.append("b") or 0), + ] + self.assertEqual(taskview.run_steps_inline(steps), 0) + self.assertEqual(order, ["a", "b"]) + + def test_returns_first_bad_rc_and_continues(self): + order = [] + steps = [ + taskview.TaskStep("a", lambda emit, cancel: order.append("a") or 1), + taskview.TaskStep("b", lambda emit, cancel: order.append("b") or 2), + ] + self.assertEqual(taskview.run_steps_inline(steps), 1) + # The second step still ran (warn-and-continue semantics). + self.assertEqual(order, ["a", "b"]) + + def test_passes_emit_and_cancel_to_each_step(self): + seen = [] + emit = object() + cancel = object() + steps = [taskview.TaskStep( + "a", lambda e, c: seen.append((e, c)) or 0)] + taskview.run_steps_inline(steps, emit=emit, cancel=cancel) + self.assertEqual(seen, [(emit, cancel)]) + + +class LineWriterTests(unittest.TestCase): + def _split(self, text): + lines = [] + writer = taskview._LineWriter(lines.append) + writer.write(text) + writer.flush() + return lines + + def test_splits_on_newline(self): + self.assertEqual(self._split("one\ntwo\n"), ["one", "two"]) + + def test_splits_on_carriage_return(self): + # git/tqdm progress updates use \r; each update is its own line. + self.assertEqual(self._split("a\rb\rc"), ["a", "b", "c"]) + + def test_handles_mixed_terminators_and_no_final_newline(self): + self.assertEqual(self._split("x\ny\r\nz"), ["x", "y", "z"]) + + +class ProgressParsingTests(_FakeTui, unittest.TestCase): + def _ingest(self, line): + view, _ = self.make_view(steps=[_step("a")]) + view._ingest_line(line) + return view + + def test_bytes_progress_is_hidden_from_the_log(self): + view = self._ingest("AUDIOCPP_PROGRESS downloaded=512 total=2048") + self.assertEqual(view._progress, (512, 2048)) + self.assertEqual(view._progress_kind, "bytes") + self.assertEqual(view.log_tail, []) + + def test_percent_progress_kept_in_log(self): + view = self._ingest("[ 45%] Building CXX object foo.o") + self.assertEqual(view._progress, (45, 100)) + self.assertEqual(view._progress_kind, "percent") + self.assertEqual(view.log_tail, ["[ 45%] Building CXX object foo.o"]) + + def test_count_progress_from_ninja(self): + view = self._ingest("[123/456] Compiling bar.cpp") + self.assertEqual(view._progress, (123, 456)) + self.assertEqual(view._progress_kind, "count") + + def test_git_percent_progress(self): + view = self._ingest("Receiving objects: 33% (99/300), 1.2 MiB") + self.assertEqual(view._progress, (33, 100)) + + def test_percent_above_one_hundred_ignored(self): + view = self._ingest("CPU 150% usage") + self.assertIsNone(view._progress) + + def test_plain_line_only_logs(self): + view = self._ingest("[INFO] doing work") + self.assertIsNone(view._progress) + self.assertEqual(view.log_tail, ["[INFO] doing work"]) + + def test_log_tail_is_capped(self): + view, _ = self.make_view(steps=[_step("a")]) + for i in range(taskview._LOG_TAIL + 5): + view._ingest_line(f"line {i}") + self.assertEqual(len(view.log_tail), taskview._LOG_TAIL) + self.assertEqual(view.log_tail[-1], f"line {taskview._LOG_TAIL + 4}") + + +class StateTransitionTests(_FakeTui, unittest.TestCase): + def _steps(self): + return [_step("one"), _step("two")] + + def test_success_flow_marks_steps_ok(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + self.assertEqual(view.current, 0) + view.handle_event({"kind": "step_done", "index": 0, "rc": 0}) + view.handle_event({"kind": "step_start", "index": 1, "title": "two"}) + view.handle_event({"kind": "step_done", "index": 1, "rc": 0}) + view.handle_event({"kind": "finish", "phase": "done", "rc": 0}) + self.assertEqual(view.phase, "done") + self.assertEqual(view._result_rc(), 0) + self.assertEqual(view._step_mark(0), ("[OK]", "ok")) + self.assertEqual(view._step_mark(1), ("[OK]", "ok")) + + def test_failure_marks_step_failed_and_returns_bad_rc(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_done", "index": 0, "rc": 7}) + view.handle_event({"kind": "finish", "phase": "error", "rc": 7}) + self.assertEqual(view.phase, "error") + self.assertEqual(view._result_rc(), 7) + self.assertEqual(view._step_mark(0), ("[FAIL]", "err")) + self.assertEqual(view._step_mark(1), ("[ ]", "dim")) + + def test_cancelled_run_returns_nonzero(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_cancelled", "index": 0}) + view.handle_event({"kind": "finish", "phase": "cancelled", "rc": 1}) + self.assertEqual(view.phase, "cancelled") + self.assertEqual(view._result_rc(), 1) + # The step interrupted by cancel is marked cancelled, not failed. + self.assertEqual(view._step_mark(0), ("[x]", "warn")) + self.assertEqual(view._step_mark(1), ("[ ]", "dim")) + + def test_running_step_shows_a_spinner_mark(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + mark, kind = view._step_mark(0) + self.assertEqual(kind, "warn") + self.assertIn("[", mark) + + +class RenderTests(_FakeTui, unittest.TestCase): + def _strings(self, screen): + return " ".join(text for _, _, text, _ in screen.strings) + + def test_running_screen_lists_steps_and_cancel_footer(self): + view, screen = self.make_view(steps=[_step("one"), _step("two")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.render() + text = self._strings(screen) + self.assertIn("one", text) + self.assertIn("two", text) + self.assertIn("Esc or q: cancel", text) + + def test_done_screen_shows_the_completion_footer(self): + view, screen = self.make_view(steps=[_step("one")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_done", "index": 0, "rc": 0}) + view.handle_event({"kind": "finish", "phase": "done", "rc": 0}) + view.render() + text = self._strings(screen) + self.assertIn("completed", text) + self.assertIn("press any key", text) + + def test_progress_bar_drawn_when_known(self): + view, screen = self.make_view(steps=[_step("one")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view._ingest_line("AUDIOCPP_PROGRESS downloaded=512 total=2048") + view.render() + text = self._strings(screen) + self.assertIn("Progress", text) + + +class LabelTests(unittest.TestCase): + def test_fmt_bytes(self): + self.assertEqual(taskview._fmt_bytes(512), "512B") + self.assertEqual(taskview._fmt_bytes(2048), "2.0KB") + self.assertEqual(taskview._fmt_bytes(5 * 1024 * 1024), "5.0MB") + + def test_progress_label_bytes(self): + self.assertEqual(taskview._progress_label((512, 2048), "bytes"), + "512B / 2.0KB") + + def test_progress_label_count(self): + self.assertEqual(taskview._progress_label((3, 10), "count"), "3/10") + + def test_progress_label_percent(self): + self.assertEqual(taskview._progress_label((45, 100), "percent"), + "45%") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 7c9c800..5b5cb3c 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -227,6 +227,33 @@ class MenuTests(TuiTestCase): with self.assertRaises(tui.WizardCancelled): tui.menu(screen, "Pick", self.OPTIONS) + def test_separator_is_blank_and_skipped_by_cursor(self): + options = [("build", "build"), tui.MENU_SEPARATOR, + ("quit", "quit")] + # Down from the first option must skip the blank divider and land on + # the third option (Enter returns its value, not the separator's). + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10]) + self.assertEqual(tui.menu(screen, "Pick", options), "quit") + + def test_separator_alone_is_rejected(self): + with self.assertRaises(ValueError): + tui.menu(self.screen, "Pick", [tui.MENU_SEPARATOR]) + + def test_suffix_renders_in_its_theme_color(self): + # default_index=1 keeps the suffixed option unselected, so its + # segments keep their own colors instead of the cursor bar. + options = [("Build audio.cpp server", "build", + ("[recommended]", "warn")), ("other", "other")] + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Pick", options, default_index=1) + text = " [recommended]" + attr = next(a for _, _, drawn, a in screen.strings + if drawn == text) + self.assertEqual(attr, tui._THEME["warn"]) + label_attr = next(a for _, _, drawn, a in screen.strings + if drawn == "Build audio.cpp server") + self.assertEqual(label_attr, tui._THEME["body"]) + class MenuTableTests(TuiTestCase): """The optional status table: aligned columns and colored statuses.""" -- cgit v1.2.3