"""Tests for the TUI hub (ui/hub.py) menu and helpers. 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 from ui import hub, tui from tests.test_tui import FakeCurses, FakeScreen class HubHelperTests(unittest.TestCase): """Pure helpers in hub.py (no curses).""" def test_is_float(self): self.assertTrue(hub._is_float("1.0")) self.assertTrue(hub._is_float("2")) self.assertFalse(hub._is_float("abc")) self.assertFalse(hub._is_float("")) def test_list_voices_from_dir(self): with __import__("tempfile").TemporaryDirectory() as td: d = Path(td) (d / "Narrator.wav").write_bytes(b"x") (d / "Alpha.WAV").write_bytes(b"x") (d / "notes.txt").write_bytes(b"x") voices = hub._list_voices(str(d)) # Stems preserve case; sorting is case-insensitive. self.assertEqual(voices, ["Alpha", "Narrator"]) def test_list_voices_missing_dir(self): self.assertEqual(hub._list_voices("/no/such/dir"), []) def test_status_mark(self): from backends import BackendStatus 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) # 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): """Drive _hub_menu with a fake screen (no terminal).""" def setUp(self): tui._THEME.clear() self.curses = FakeCurses() from unittest.mock import patch as _patch self._patcher = _patch.dict("sys.modules", {"curses": self.curses}) self._patcher.start() self.addCleanup(self._patcher.stop) self.addCleanup(tui._THEME.clear) 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_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=True, configured=False) infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none, lambda: 0), BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0), BackendInfo("faster", "faster", lambda: none, lambda: 0)] # 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]) result = hub._hub_menu(screen) self.assertIsNone(result) if __name__ == "__main__": unittest.main()