From 194c63e4d11e6de9792a736a7b99788f1db78741 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 00:41:52 -0400 Subject: feat: running process detection, menu gating --- tests/test_backends.py | 92 +++++++++++++++++++++++++++--- tests/test_hub.py | 150 +++++++++++++++++++++++++++++++++++++++++-------- tests/test_tui.py | 81 +++++++++++++++++++++++++- 3 files changed, 291 insertions(+), 32 deletions(-) (limited to 'tests') diff --git a/tests/test_backends.py b/tests/test_backends.py index 4017cd4..8ee1be8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -17,8 +17,8 @@ class RegistryTests(unittest.TestCase): for info in REGISTRY: self.assertTrue(callable(info.detect), info.key) self.assertTrue(callable(info.setup_tui), info.key) - self.assertIsInstance(info.modify_actions, list) - for action in info.modify_actions: + self.assertIsInstance(info.configure_actions, list) + for action in info.configure_actions: self.assertTrue(callable(action.run)) def test_get_returns_entry_by_key(self): @@ -28,7 +28,8 @@ class RegistryTests(unittest.TestCase): class DetectAllTests(unittest.TestCase): def test_detect_all_returns_one_status_per_backend(self): - statuses = detect_all() + with patch("backends.common.server_running", return_value=False): + statuses = detect_all() self.assertEqual([s.key for s in statuses], ["audiocpp", "qwen", "faster"]) for s in statuses: @@ -37,6 +38,9 @@ class DetectAllTests(unittest.TestCase): # machine none are ready. if s.ready: self.assertTrue(s.installed and s.configured) + # running is always probed; patched False here so a dev machine + # running a real server can't flake the test. + self.assertFalse(s.running) def test_audiocpp_status_when_cloned_built_configured(self): with tempfile.TemporaryDirectory() as td: @@ -52,25 +56,55 @@ class DetectAllTests(unittest.TestCase): encoding="utf-8") from backends import audiocpp with patch.object(audiocpp, "find_local_checkout", - return_value=checkout): + return_value=checkout), \ + patch("backends.common.server_running", + return_value=False): status = audiocpp.detect() self.assertTrue(status.installed) self.assertTrue(status.configured) self.assertTrue(status.ready) + self.assertFalse(status.running) self.assertIn("audiocpp_server", status.launch_hint) + def test_audiocpp_running_when_server_probe_succeeds(self): + from backends import audiocpp + with patch.object(audiocpp, "find_local_checkout", + return_value=None), \ + patch("backends.common.server_running", return_value=True): + status = audiocpp.detect() + # Not installed (no checkout) but an external server is up. + self.assertFalse(status.installed) + self.assertTrue(status.running) + def test_qwen_status_reflects_install(self): from backends import qwen - with patch.object(qwen, "_is_installed", return_value=True): + with patch.object(qwen, "_is_installed", return_value=True), \ + patch("backends.common.server_running", return_value=False): status = qwen.detect() self.assertTrue(status.installed) self.assertTrue(status.configured) + self.assertFalse(status.running) self.assertIn("qwen-tts-demo", status.launch_hint) - with patch.object(qwen, "_is_installed", return_value=False): + with patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", return_value=False): status = qwen.detect() self.assertFalse(status.installed) self.assertFalse(status.configured) + def test_qwen_running_when_either_port_is_up(self): + # Either the CustomVoice port or the Base port counts as running. + from backends import qwen + with patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", + side_effect=[True, False]): + status = qwen.detect() + self.assertTrue(status.running) + with patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", + side_effect=[False, True]): + status = qwen.detect() + self.assertTrue(status.running) + def test_faster_status_reflects_install_clone_voices(self): from backends import faster with tempfile.TemporaryDirectory() as td: @@ -80,12 +114,56 @@ class DetectAllTests(unittest.TestCase): (checkout / "voices.json").write_text('{"default":{}}', encoding="utf-8") with patch.object(faster, "_is_installed", return_value=True), \ - patch.object(faster, "_checkout", return_value=checkout): + patch.object(faster, "_checkout", + return_value=checkout), \ + patch("backends.common.server_running", + return_value=False): status = faster.detect() self.assertTrue(status.installed) self.assertTrue(status.configured) + self.assertFalse(status.running) self.assertIn("openai_server.py", status.launch_hint) + def test_faster_running_when_server_probe_succeeds(self): + from backends import faster + with patch.object(faster, "_is_installed", return_value=False), \ + patch.object(faster, "_is_cloned", return_value=False), \ + patch("backends.common.server_running", return_value=True): + status = faster.detect() + self.assertTrue(status.running) + + +class ServerRunningTests(unittest.TestCase): + """backends.common.server_running: TCP probe against a real socket.""" + + def test_true_for_open_port(self): + import socket + from backends import common + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(1) + host, port = server.getsockname() + url = f"http://127.0.0.1:{port}" + try: + self.assertTrue(common.server_running(url)) + finally: + server.close() + + def test_false_for_closed_port(self): + from backends import common + # Pick an unused port by opening + closing a socket, then probe it. + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + _, port = s.getsockname() + s.close() + self.assertFalse(common.server_running(f"http://127.0.0.1:{port}")) + + def test_false_for_invalid_url(self): + from backends import common + self.assertFalse(common.server_running("not a url")) + self.assertFalse(common.server_running("")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hub.py b/tests/test_hub.py index 5f6d992..ce9af43 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1,15 +1,14 @@ -"""Tests for the TUI hub (hub.py) menu and helpers. +"""Tests for the TUI hub (ui/hub.py) menu and helpers. -The hub drives the same curses widgets as tui.py, so these tests reuse the -fake curses/screen from test_tui to run the menu without a terminal. +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 unittest from pathlib import Path from unittest.mock import patch -import hub -import tui +from ui import hub, tui from tests.test_tui import FakeCurses, FakeScreen @@ -37,13 +36,21 @@ class HubHelperTests(unittest.TestCase): def test_status_mark(self): from backends import BackendStatus - ready = BackendStatus("k", "l", installed=True, configured=True) - half = BackendStatus("k", "l", installed=True, configured=False) + running = BackendStatus("k", "l", installed=True, configured=True, + running=True) + installed = BackendStatus("k", "l", installed=True, + configured=False) none = BackendStatus("k", "l", installed=False, configured=False) - self.assertEqual(hub._status_mark("k", [ready]), "ready") - self.assertEqual(hub._status_mark("k", [half]), "installed") - self.assertEqual(hub._status_mark("k", [none]), "not set up") - self.assertEqual(hub._status_mark("missing", []), "not set up") + # 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(running), + ("running", "ok", "body")) + self.assertEqual(hub._status_mark(installed), + ("installed", "warn", "body")) + self.assertEqual(hub._status_mark(none), + ("unavailable", "err", "dim")) + self.assertEqual(hub._status_mark(None), + ("unavailable", "err", "dim")) class HubMenuTests(unittest.TestCase): @@ -58,31 +65,126 @@ class HubMenuTests(unittest.TestCase): self.addCleanup(self._patcher.stop) self.addCleanup(tui._THEME.clear) - def test_quit_returns_none(self): - # Main menu: move to "Quit" (4th option, index 3) and press Enter. - screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, 10]) + def _none_status(self, key="k", label="l"): + from backends import BackendStatus + return BackendStatus(key, label, installed=False, configured=False) + + def test_quit_returns_none_when_no_backend(self): + # No backends installed/running: menu is [Set up, Quit]. Quit is the + # 2nd option (Down once) then Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10]) with patch.object(hub, "detect_all", return_value=[]): result = hub._hub_menu(screen) self.assertIsNone(result) - def test_convert_with_no_ready_backend_offers_setup(self): - # Convert -> "Set up a backend..." is the only entry -> Enter selects - # it -> setup menu lists 3 backends; press Esc to go back -> convert - # returns None -> main menu loops. Then quit (Down x3 + Enter). + def test_menu_has_only_setup_and_quit_without_backends(self): + # Capture the options handed to tui.menu: with nothing installed or + # running, Convert/Configure must be absent. + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return "quit" + + screen = FakeScreen() + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "detect_all", return_value=[]): + hub._hub_menu(screen) + labels = [label for label, _ in captured["options"]] + self.assertEqual(labels, ["Set up a backend...", "Quit"]) + + def test_menu_has_all_four_when_one_installed(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + captured["rows"] = kwargs.get("table_rows") + return "quit" + + screen = FakeScreen() + st = self._none_status("qwen", "qwen-tts") + st.installed = True + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "detect_all", return_value=[st]): + hub._hub_menu(screen) + labels = [label for label, _ in captured["options"]] + self.assertEqual( + labels, + ["Convert books...", "Set up a backend...", + "Configure a backend...", "Quit"]) + # The status table is passed through, one row per backend. + self.assertEqual(captured["rows"], + [("qwen-tts", "installed", "warn", "body")]) + + def test_table_dims_name_when_not_installed_and_not_running(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["rows"] = kwargs.get("table_rows") + return "quit" + + screen = FakeScreen() + dead = self._none_status("audiocpp", "audio.cpp") + external = self._none_status("qwen", "qwen-tts") + external.running = True + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "detect_all", + return_value=[dead, external]): + hub._hub_menu(screen) + # Unusable backend: dim name. Running-but-not-installed stays bright. + self.assertEqual( + captured["rows"], + [("audio.cpp", "unavailable", "err", "dim"), + ("qwen-tts", "running", "ok", "body")]) + + def test_menu_has_all_four_when_one_running_only(self): + # Running but not installed (an external server) still unlocks the + # Convert/Configure entries. + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return "quit" + + screen = FakeScreen() + st = self._none_status("qwen", "qwen-tts") + st.running = True + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "detect_all", return_value=[st]): + hub._hub_menu(screen) + labels = [label for label, _ in captured["options"]] + self.assertEqual( + labels, + ["Convert books...", "Set up a backend...", + "Configure a backend...", "Quit"]) + + def test_convert_with_no_available_backend_offers_setup(self): + # One installed-but-not-ready backend → Convert is offered. The + # convert menu lists no available backend, so only "Set up a + # backend..." is shown; Enter selects it → setup menu lists 3 + # backends; Esc goes back → convert returns None → main menu loops. + # Then quit: main menu now has 4 options, Quit is the 4th (Down x3). from backends import BackendInfo, BackendStatus - none = BackendStatus("k", "l", installed=False, configured=False) + none = BackendStatus("k", "l", installed=True, configured=False) infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none, lambda: 0), - BackendInfo("qwen", "Qwen", lambda: none, lambda: 0), + BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0), BackendInfo("faster", "faster", lambda: none, lambda: 0)] - with patch.object(hub, "detect_all", return_value=[none, none, none]), \ + # installed=True so the main menu shows Convert; but ready/running + # is False so the convert menu's available list is empty. + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=False), + BackendStatus("qwen", "qwen-tts", installed=True, + configured=False), + BackendStatus("faster", "faster", installed=True, + configured=False)] + with patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub, "REGISTRY", infos): # Convert(Enter), setup-entry(Enter), Esc on setup menu, # back at main menu -> Down x3 -> Enter (Quit). screen = FakeScreen(keys=[10, 10, 27, - FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, 10]) + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10]) result = hub._hub_menu(screen) self.assertIsNone(result) diff --git a/tests/test_tui.py b/tests/test_tui.py index ba6f99f..58d8273 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -14,7 +14,7 @@ import unittest from pathlib import Path from unittest.mock import patch -import tui +from ui import tui class FakeCurses: @@ -222,6 +222,85 @@ class MenuTests(TuiTestCase): tui.menu(screen, "Pick", self.OPTIONS, back_value=marker) +class MenuTableTests(TuiTestCase): + """The optional status table: aligned columns and colored statuses.""" + + ROWS = [("audio.cpp", "not installed", "err"), + ("qwen-tts", "installed", "warn"), + ("faster-qwen3-tts", "running", "ok")] + + def test_name_column_left_aligned_at_margin(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + x0, _ = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + for name, _, _ in self.ROWS: + x = next(x for _, x, text, _ in screen.strings + if text.rstrip() == name) + self.assertEqual(x, margin, name) + + def test_status_column_aligned_at_one_fixed_offset(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + x0, _ = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + name_w = max(len(name) for name, _, _ in self.ROWS) + expected_x = margin + name_w # the " status" segment starts here + for _, status, _ in self.ROWS: + x = next(x for _, x, text, _ in screen.strings + if text.strip() == status) + self.assertEqual(x, expected_x, status) + + def test_status_text_uses_the_theme_kind_color(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"], + "ok": tui._THEME["ok"]} + for _, status, kind in self.ROWS: + attr = next(a for _, _, text, a in screen.strings + if text.strip() == status) + self.assertEqual(attr, want[kind], status) + + def test_optional_name_kind_colors_the_name_column(self): + # 4-element rows: the 4th value is a theme kind for the name. + rows = [("gone", "unavailable", "err", "dim"), + ("here", "running", "ok", "body")] + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], table_rows=rows) + drawn = {text.rstrip(): attr for _, _, text, attr in screen.strings} + self.assertEqual(drawn["gone"], tui._THEME["dim"]) + self.assertEqual(drawn["here"], tui._THEME["body"]) + + def test_three_element_rows_default_to_body_names(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + for name, _, _ in self.ROWS: + attr = next(a for _, _, text, a in screen.strings + if text.rstrip() == name) + self.assertEqual(attr, tui._THEME["body"], name) + + def test_table_title_is_dim_and_left_aligned(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + x0, _ = self.dialog_box(screen) + margin = x0 + 1 + tui.Frame.LIST_MARGIN + x, attr = next((x, a) for _, x, text, a in screen.strings + if text == "Backend status") + self.assertEqual(x, margin) + self.assertEqual(attr, tui._THEME["dim"]) + + def test_table_does_not_paint_over_the_border(self): + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Hub", [("Quit", "quit")], + table_title="Backend status", table_rows=self.ROWS) + self.assert_inside_border(screen) + + class ConfirmTests(TuiTestCase): def test_tab_switches_and_enter_activates(self): screen = FakeScreen(keys=[9, 10]) -- cgit v1.2.3