aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
committerhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
commitfe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch)
tree9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/tests
parentf4b1de303704e13818259d5057d176cd841b6ed8 (diff)
downloadtts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/tests')
-rw-r--r--app/tests/test_backends.py20
-rw-r--r--app/tests/test_backends_audiocpp.py219
-rw-r--r--app/tests/test_backends_common.py76
-rw-r--r--app/tests/test_backends_envs.py2
-rw-r--r--app/tests/test_backends_faster.py24
-rw-r--r--app/tests/test_hub.py242
-rw-r--r--app/tests/test_taskview.py231
-rw-r--r--app/tests/test_tui.py27
8 files changed, 795 insertions, 46 deletions
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."""