diff options
| author | historia <historiavg@proton.me> | 2026-08-24 04:53:43 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 04:53:43 -0400 |
| commit | 0512a932bbf3b87619b6a250f87eb7904d22d1f7 (patch) | |
| tree | dd2b0eb45823c0690ad2bc86f5aa9be2771f5b17 /app/tests | |
| parent | 9fb2434dcc8a1ed7bd085b453859d473de64448a (diff) | |
| download | tts-audiobook-generator-0512a932bbf3b87619b6a250f87eb7904d22d1f7.tar.gz | |
feat: hide convert/config/start-stop menus if no valid installation
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends.py | 40 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 51 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 227 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 11 |
4 files changed, 296 insertions, 33 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index c0e8d4a..ac4c8ef 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -99,18 +99,56 @@ class DetectAllTests(unittest.TestCase): 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. + # Either the CustomVoice port or the Base port counts as running, + # and the status names which model answered. Probes: CustomVoice + # (QWEN_API_URL) first, then Base (CLONE_API_URL). 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) + self.assertEqual(status.running_models, ["CustomVoice"]) 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) + self.assertEqual(status.running_models, ["Base"]) + + def test_qwen_running_models_names_both_ports(self): + # Both ports up → both models, Base first (the hub renders + # "running (Base, CustomVoice)"). + from backends import qwen + with patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", + side_effect=[True, True]): + status = qwen.detect() + self.assertTrue(status.running) + self.assertEqual(status.running_models, ["Base", "CustomVoice"]) + + def test_qwen_detect_marks_our_server_as_managed(self): + from backends import qwen + from backends import servers as servers_mod + with tempfile.TemporaryDirectory() as td: + (Path(td) / "qwen-custom-server.pid").write_text( + "4242", encoding="utf-8") + with patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", + return_value=False), \ + patch.object(servers_mod, "LOG_DIR", Path(td)), \ + patch.object(servers_mod, "_pid_alive", + return_value=True): + status = qwen.detect() + self.assertTrue(status.managed) + # Without a live pid file the same server counts as remote. + with tempfile.TemporaryDirectory() as td, \ + patch.object(qwen, "_is_installed", return_value=False), \ + patch("backends.common.server_running", + return_value=False), \ + patch.object(servers_mod, "LOG_DIR", Path(td)): + status = qwen.detect() + self.assertFalse(status.managed) def test_faster_status_reflects_install_clone_voices(self): from backends import faster diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py index 02b65e6..987ff24 100644 --- a/app/tests/test_backends_servers.py +++ b/app/tests/test_backends_servers.py @@ -123,6 +123,57 @@ class StopTests(unittest.TestCase): self.assertFalse((self.dir / "test-server.pid").exists()) +class ManagesTests(unittest.TestCase): + """manages(): a live recorded pid marks a server as ours.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + self.specs = [ServerSpec("test", "http://127.0.0.1:9999", [])] + + def tearDown(self): + self._tmp.cleanup() + + def _write_pid(self, name, pid): + (self.dir / f"{name}-server.pid").write_text(str(pid), + encoding="utf-8") + + def test_false_without_pid_file(self): + with patch.object(servers, "LOG_DIR", self.dir): + self.assertFalse(servers.manages(self.specs)) + + def test_true_with_live_recorded_pid(self): + self._write_pid("test", 4242) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=True): + self.assertTrue(servers.manages(self.specs)) + + def test_false_with_dead_recorded_pid(self): + self._write_pid("test", 4242) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=False): + self.assertFalse(servers.manages(self.specs)) + + def test_false_with_corrupt_pid_file(self): + (self.dir / "test-server.pid").write_text("junk", + encoding="utf-8") + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", + return_value=True) as mk_alive: + self.assertFalse(servers.manages(self.specs)) + mk_alive.assert_not_called() + + def test_true_when_any_spec_is_ours(self): + other = ServerSpec("other", "http://127.0.0.1:9998", []) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=True): + self._write_pid("test", 4242) + self.assertTrue(servers.manages([other] + self.specs)) + # The live pid belongs to 'test'; 'other' alone stays unmanaged. + with patch.object(servers, "LOG_DIR", self.dir): + self.assertFalse(servers.manages([other])) + + class PidForTests(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 0e7c2bd..61f0da1 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -8,7 +8,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from backends import BackendStatus, ServerSpec +from backends import BackendInfo, BackendStatus, ServerSpec from tests.test_tui import FakeCurses, FakeScreen from ui import hub, tui @@ -38,7 +38,15 @@ class HubHelperTests(unittest.TestCase): def test_status_mark(self): from backends import BackendStatus running = BackendStatus("k", "l", installed=True, configured=True, - running=True) + running=True, managed=True) + remote = BackendStatus("k", "l", installed=True, configured=True, + running=True) + models = BackendStatus("k", "l", installed=True, configured=True, + running=True, managed=True, + running_models=["Base", "CustomVoice"]) + remote_models = BackendStatus("k", "l", installed=True, + configured=True, running=True, + running_models=["Base"]) installed = BackendStatus("k", "l", installed=True, configured=False) none = BackendStatus("k", "l", installed=False, configured=False) @@ -46,6 +54,14 @@ class HubHelperTests(unittest.TestCase): # only a backend that is neither installed nor running is dimmed. self.assertEqual(hub._status_mark(running), ("running", "ok", "body")) + # A server without a live recorded pid was started externally. + self.assertEqual(hub._status_mark(remote), + ("running [remote]", "ok", "body")) + # Multi-model backends name the models that answered. + self.assertEqual(hub._status_mark(models), + ("running (Base, CustomVoice)", "ok", "body")) + self.assertEqual(hub._status_mark(remote_models), + ("running [remote] (Base)", "ok", "body")) self.assertEqual(hub._status_mark(installed), ("installed", "warn", "body")) self.assertEqual(hub._status_mark(none), @@ -133,15 +149,16 @@ class HubMenuTests(unittest.TestCase): patch.object(hub, "detect_all", return_value=[dead, external]): hub._hub_menu(screen) - # Unusable backend: dim name. Running-but-not-installed stays bright. + # Unusable backend: dim name. Running-but-not-installed stays + # bright and is tagged remote (no pid file → not started by us). self.assertEqual( captured["rows"], [("audio.cpp", "unavailable", "err", "dim"), - ("qwen-tts", "running", "ok", "body")]) + ("qwen-tts", "running [remote]", "ok", "body")]) - def test_menu_has_all_six_when_one_running_only(self): - # Running but not installed (an external server) still unlocks the - # Convert/Configure/Server entries. + def test_menu_hides_configure_and_server_when_only_running(self): + # Running but not installed (an external server) still unlocks + # Convert — but Configure/Server need the backend on this machine. captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -157,9 +174,7 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Convert books", "Set up a backend", - "Configure a backend", "Start/Stop Backend Servers", - "Settings", "Quit"]) + ["Convert books", "Set up a backend", "Settings", "Quit"]) def test_ffmpeg_warning_shown_when_missing(self): # ffmpeg not on PATH → a red notice is passed above the table. @@ -192,37 +207,182 @@ class HubMenuTests(unittest.TestCase): hub._hub_menu(screen) self.assertIsNone(captured["notice_lines"]) - 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 5 options, Quit is the 5th (Down x4). - 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. + def test_convert_with_no_available_backend_flashes(self): + # Installed-but-not-ready backends → Convert is offered, but the + # convert flow has nothing to list: it flashes a hint (no "Set up + # a backend" detour anymore) and returns to the main menu. Then + # quit: 6 main-menu options, Quit is the 6th (Down x5). + from backends import BackendStatus statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, configured=False), BackendStatus("qwen", "qwen-tts", installed=True, configured=False), BackendStatus("faster", "faster", installed=True, configured=False)] + flashed = [] + + def fake_flash(stdscr, text, kind="warn"): + flashed.append(text) + 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 x5 -> Enter (Quit; Settings sits - # just before it). - screen = FakeScreen(keys=[10, 10, 27, - FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, 10]) + patch.object(hub.tui, "flash", fake_flash): + # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter. + screen = FakeScreen(keys=[10, + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10]) result = hub._hub_menu(screen) self.assertIsNone(result) + self.assertEqual(len(flashed), 1) + self.assertIn("No backend is ready", flashed[0]) + + +class SubmenuStatusTableTests(unittest.TestCase): + """First picker screen of every flow repeats the backend status table. + + Entries themselves stay clean: setup lists bare labels, and the + Start/Stop menu offers only installed backends. + """ + + def _capture_menu(self, captured): + def fake_menu(stdscr, title, options, **kwargs): + captured["title"] = title + captured["options"] = options + captured.update(kwargs) + return hub._GO_BACK # Esc: back out immediately + + return fake_menu + + def test_setup_menu_lists_bare_labels_and_status_table(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0), + BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [ + BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True), + BackendStatus("qwen", "qwen-tts", installed=False, + configured=False, running=True), + ] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", + return_value="/usr/bin/ffmpeg"): + result = hub._setup_menu(None, statuses) + self.assertIsNone(result) + # No inline "(running)"-style suffix on the entries anymore... + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp", "qwen-tts"]) + # ...the shared status table carries the states instead. + self.assertEqual(captured["table_title"], "Backend status") + self.assertEqual( + captured["table_rows"], + [("audio.cpp", "installed", "warn", "body"), + ("qwen-tts", "running [remote]", "ok", "body")]) + self.assertIsNone(captured["notice_lines"]) + + def test_convert_menu_shows_status_table(self): + captured = {} + st = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True) + with patch.object(hub.tui, "menu", self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._convert_menu(None, [st]) + self.assertIsNone(result) + self.assertEqual(captured["title"], "Convert books with...") + # Only convertible backends are listed — no "Set up a backend" + # detour inside the Convert flow. + self.assertEqual([label for label, _ in captured["options"]], + ["qwen-tts"]) + self.assertEqual( + captured["table_rows"], [("qwen-tts", "installed", "warn", + "body")]) + + def test_convert_with_nothing_ready_flashes_instead_of_menu(self): + # Installed-but-unconfigured → nothing convertible: a hint flash + # replaces the old fallback menu entirely. + flashed = [] + menus = [] + + def fake_menu(*args, **kwargs): + menus.append((args, kwargs)) + return hub._GO_BACK + + def fake_flash(stdscr, text, kind="warn"): + flashed.append(text) + + st = BackendStatus("qwen", "qwen-tts", installed=True, + configured=False) + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.tui, "flash", fake_flash): + result = hub._convert_menu(None, [st]) + self.assertIsNone(result) + self.assertEqual(menus, []) + self.assertIn("No backend is ready", flashed[0]) + + def test_configure_menu_shows_status_table(self): + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._configure_menu(None, statuses) + self.assertIsNone(result) + self.assertEqual(captured["table_title"], "Backend status") + self.assertEqual( + captured["table_rows"], [("qwen-tts", "installed", "warn", + "body")]) + + def test_server_menu_lists_only_installed_backends(self): + captured = {} + installed = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True) + remote = BackendStatus("qwen", "qwen-tts", installed=False, + configured=False, running=True) + gone = BackendStatus("faster", "faster-qwen3-tts", installed=False, + configured=False) + with patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._server_menu(None, [installed, remote, gone]) + self.assertIsNone(result) + # Only the installed backend is offered; a running external server + # (remote) can't be stopped from here and must not appear. + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp"]) + # The status table still shows all three, states included. + self.assertEqual([row[0] for row in captured["table_rows"]], + ["audio.cpp", "qwen-tts", "faster-qwen3-tts"]) + + def test_server_menu_flashes_when_nothing_installed(self): + flashed = [] + + def fake_flash(stdscr, text, kind="warn"): + flashed.append(text) + + remote = BackendStatus("qwen", "qwen-tts", installed=False, + configured=False, running=True) + with patch.object(hub.tui, "flash", fake_flash): + result = hub._server_menu(None, [remote]) + self.assertIsNone(result) + self.assertEqual(len(flashed), 1) + self.assertIn("No backend is installed", flashed[0]) + + def test_submenu_repeats_ffmpeg_warning(self): + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value=None): + hub._setup_menu(None, statuses) + self.assertEqual(captured["notice_lines"], + [("Warning: ffmpeg not installed!", "err")]) class SelectSpecTests(unittest.TestCase): @@ -492,6 +652,9 @@ class SettingsTests(unittest.TestCase): self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") self.assertEqual(kinds["audiocpp_port"], "text") + labels = {f["key"]: f["label"] for f in captured["fields"]} + self.assertEqual(labels["qwen_clone_port"], "qwen-tts Base port") + self.assertNotIn("(clone)", " ".join(labels.values())) # The ports section note hangs off the first port field so it # renders between the output settings and the ports. notes = {f["key"]: f.get("note") for f in captured["fields"]} diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 38f4fa9..e7fd976 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -751,6 +751,17 @@ class FlashTests(TuiTestCase): tui.flash(screen, "a notice", kind="warn") self.assertEqual(screen.keys, []) + def test_message_is_shown_without_a_notice_heading(self): + screen = FakeScreen(keys=[10]) + tui.flash(screen, "No backend is installed", kind="warn") + texts = [text for _, _, text, _ in screen.strings] + self.assertNotIn("Notice", texts) + # The warning itself is the dialog's content, in its kind color. + attr = next(a for _, _, text, a in screen.strings + if text == "No backend is installed") + self.assertEqual(attr, tui._THEME["warn"]) + self.assert_inside_border(screen) + if __name__ == "__main__": unittest.main() |
