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_audiocpp.py | 219 ++++++++++++++++++++++++++++++++++-- 1 file changed, 207 insertions(+), 12 deletions(-) (limited to 'app/tests/test_backends_audiocpp.py') 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) -- cgit v1.2.3