aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_backends.py92
-rw-r--r--tests/test_hub.py150
-rw-r--r--tests/test_tui.py81
3 files changed, 291 insertions, 32 deletions
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])