diff options
| author | historia <historiavg@proton.me> | 2026-08-24 20:17:11 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 20:17:11 -0400 |
| commit | cf24fa74188cee498eeb7b94422371c952278d4a (patch) | |
| tree | 302244a8b09b9173fbde542874cfa549f26be7a2 /app/tests/test_hub.py | |
| parent | 0522e73b68291af62e43c387ab8f9b8ffa2cab47 (diff) | |
| download | tts-audiobook-generator-cf24fa74188cee498eeb7b94422371c952278d4a.tar.gz | |
fix: stepping back steps in tui
Diffstat (limited to 'app/tests/test_hub.py')
| -rw-r--r-- | app/tests/test_hub.py | 350 |
1 files changed, 251 insertions, 99 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index a8e3ac1..d6340da 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -113,7 +113,7 @@ class HubHelperTests(unittest.TestCase): class HubMenuTests(unittest.TestCase): - """Drive _hub_menu with a fake screen (no terminal).""" + """Drive the hub's screen stack with a fake screen (no terminal).""" def setUp(self): tui._THEME.clear() @@ -133,7 +133,7 @@ class HubMenuTests(unittest.TestCase): # Settings, Quit]. Quit is the 3rd option (Down twice) then Enter. screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]) with patch.object(hub, "detect_all", return_value=[]): - result = hub._hub_menu(screen) + result = hub._Hub(screen).run() self.assertIsNone(result) def test_menu_has_only_configure_settings_and_quit_without_backends(self): @@ -148,7 +148,7 @@ class HubMenuTests(unittest.TestCase): screen = FakeScreen() with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[]): - hub._hub_menu(screen) + hub._Hub(screen).run() labels = [label for label, _ in captured["options"]] self.assertEqual(labels, ["Configure backends", "Settings", "Quit"]) @@ -165,7 +165,7 @@ class HubMenuTests(unittest.TestCase): st.installed = True with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[st]): - hub._hub_menu(screen) + hub._Hub(screen).run() labels = [label for label, _ in captured["options"]] self.assertEqual( labels, @@ -190,7 +190,7 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[dead, external]): - hub._hub_menu(screen) + hub._Hub(screen).run() # Unusable backend: dim name. Running-but-not-installed stays # bright and is tagged remote (found at its remote URL). self.assertEqual( @@ -212,7 +212,7 @@ class HubMenuTests(unittest.TestCase): st.running = True with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[st]): - hub._hub_menu(screen) + hub._Hub(screen).run() labels = [label for label, _ in captured["options"]] self.assertEqual( labels, @@ -230,7 +230,7 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub.shutil, "which", return_value=None): - hub._hub_menu(screen) + hub._Hub(screen).run() self.assertEqual(captured["notice_lines"], [("Warning: ffmpeg not installed!", "err")]) @@ -246,7 +246,7 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub.shutil, "which", return_value="/usr/bin/ffmpeg"): - hub._hub_menu(screen) + hub._Hub(screen).run() self.assertIsNone(captured["notice_lines"]) def test_convert_with_no_available_backend_flashes(self): @@ -273,7 +273,7 @@ class HubMenuTests(unittest.TestCase): FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]) - result = hub._hub_menu(screen) + result = hub._Hub(screen).run() self.assertIsNone(result) self.assertEqual(len(flashed), 1) self.assertIn("No backend is ready", flashed[0]) @@ -291,7 +291,7 @@ class SubmenuStatusTableTests(unittest.TestCase): captured["title"] = title captured["options"] = options captured.update(kwargs) - return hub._GO_BACK # Esc: back out immediately + return tui.Wizard.BACK # Esc: back out immediately return fake_menu @@ -300,7 +300,7 @@ class SubmenuStatusTableTests(unittest.TestCase): captured["title"] = title captured["fields"] = fields captured.update(kwargs) - return hub._GO_BACK # Cancel: back out immediately + return tui.Wizard.BACK # Cancel: back out immediately return fake_form @@ -316,16 +316,17 @@ class SubmenuStatusTableTests(unittest.TestCase): configured=False, running=True, remote=True), ] 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.shutil, "which", return_value="/usr/bin/ffmpeg"): - result = hub._configure_backends_menu(None, statuses) - self.assertIsNone(result) - # Install (faster uninstalled), Configure (qwen installed), then + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + # Configure (qwen installed), Install (faster uninstalled), then # Uninstall (qwen installed); no audio.cpp means no model actions. self.assertEqual([label for label, _ in captured["options"]], - ["Install Backend", "Configure qwen-tts", + ["Configure qwen-tts", "Install Backend", "Uninstall Backend"]) # ...the shared status table carries the states instead. self.assertEqual(captured["table_title"], "Backend status") @@ -341,11 +342,12 @@ class SubmenuStatusTableTests(unittest.TestCase): statuses = [BackendStatus("qwen", "qwen-tts", installed=False, configured=False)] 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.shutil, "which", return_value="/x"): - result = hub._configure_backends_menu(None, statuses) - self.assertIsNone(result) + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) # Nothing installed: only the install entry is offered. self.assertEqual([label for label, _ in captured["options"]], ["Install Backend"]) @@ -364,13 +366,14 @@ class SubmenuStatusTableTests(unittest.TestCase): (checkout / "models" / "present").mkdir(parents=True) (checkout / "models" / "present" / "m.gguf").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._configure_backends_menu(None, statuses) - self.assertIsNone(result) + 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 @@ -386,9 +389,10 @@ class SubmenuStatusTableTests(unittest.TestCase): configured=True) with patch.object(hub.tui, "form", self._capture_form(captured)), \ + patch.object(hub, "detect_all", return_value=[st]), \ patch.object(hub.shutil, "which", return_value="/x"): - result = hub._convert_menu(None, [st]) - self.assertIsNone(result) + result = hub._Hub(None).screen_convert() + self.assertIs(result, tui.Wizard.BACK) self.assertEqual(captured["title"], "Convert books") # One form, no picker menu: the first field is the Backend picker, # and only convertible backends are offered in it. @@ -406,7 +410,7 @@ class SubmenuStatusTableTests(unittest.TestCase): def fake_menu(*args, **kwargs): menus.append((args, kwargs)) - return hub._GO_BACK + return tui.Wizard.BACK def fake_flash(stdscr, text, kind="warn"): flashed.append(text) @@ -414,9 +418,10 @@ class SubmenuStatusTableTests(unittest.TestCase): 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) + patch.object(hub.tui, "flash", fake_flash), \ + patch.object(hub, "detect_all", return_value=[st]): + result = hub._Hub(None).screen_convert() + self.assertIs(result, tui.Wizard.BACK) self.assertEqual(menus, []) self.assertIn("No backend is ready", flashed[0]) @@ -426,11 +431,12 @@ class SubmenuStatusTableTests(unittest.TestCase): statuses = [BackendStatus("qwen", "qwen-tts", installed=True, configured=True)] 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.shutil, "which", return_value="/x"): - result = hub._configure_backends_menu(None, statuses) - self.assertIsNone(result) + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( captured["table_rows"], [("qwen-tts", "installed", "warn", @@ -446,9 +452,11 @@ class SubmenuStatusTableTests(unittest.TestCase): configured=False) with patch.object(hub.tui, "menu", self._capture_menu(captured)), \ + patch.object(hub, "detect_all", + return_value=[installed, remote, gone]), \ patch.object(hub.shutil, "which", return_value="/x"): - result = hub._server_menu(None, [installed, remote, gone]) - self.assertIsNone(result) + result = hub._Hub(None).screen_server() + self.assertIs(result, tui.Wizard.BACK) # 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"]], @@ -465,9 +473,10 @@ class SubmenuStatusTableTests(unittest.TestCase): 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) + with patch.object(hub.tui, "flash", fake_flash), \ + patch.object(hub, "detect_all", return_value=[remote]): + result = hub._Hub(None).screen_server() + self.assertIs(result, tui.Wizard.BACK) self.assertEqual(len(flashed), 1) self.assertIn("No backend is installed", flashed[0]) @@ -477,18 +486,19 @@ class SubmenuStatusTableTests(unittest.TestCase): statuses = [BackendStatus("qwen", "qwen-tts", installed=True, configured=True)] 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.shutil, "which", return_value=None): - hub._configure_backends_menu(None, statuses) + hub._Hub(None).screen_configure() self.assertEqual(captured["notice_lines"], [("Warning: ffmpeg not installed!", "err")]) class ConvertFlowTests(unittest.TestCase): - """_convert_menu: one form whose first field is the Backend picker, - followed by that backend's options (local config or live remote - queries).""" + """_convert_form / screen_convert: one form whose first field is the + Backend picker, followed by that backend's options (local config or live + remote queries).""" def setUp(self): self.tui = _ScriptedTUI() @@ -515,6 +525,27 @@ class ConvertFlowTests(unittest.TestCase): """A backend status that is ready to convert with.""" return BackendStatus(key, label, installed=True, configured=True) + def _convert(self, stdscr, statuses): + """Run the convert flow with STATUSES, returning the command tuple. + + ``_run_conversion`` is stubbed so the accepted command is captured + instead of launching the run view; None is returned when the flow + aborts before reaching a conversion (nothing ready, a flash). + """ + captured = {} + + def fake_run_conversion(self_, backend, kwargs): + captured["backend"] = backend + captured["kwargs"] = kwargs + + with patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub._Hub, "_run_conversion", + fake_run_conversion): + hub._Hub(None).screen_convert() + if "backend" not in captured: + return None + return ("convert", captured["backend"], captured["kwargs"]) + # ------------------------------------------------------------------ # audio.cpp: remote server (no local checkout / server.json) # ------------------------------------------------------------------ @@ -550,7 +581,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="", speed="1.5") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) @@ -588,7 +619,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="audiocpp-remote", model_id="qwen", audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) # The sentinel maps to "no voice" (built-in speaker). self.assertIsNone(cmd[2]["voice"]) @@ -608,7 +639,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="audiocpp-remote", model_id="legacy", audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) @@ -620,7 +651,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="audiocpp-remote", model_id="design", audiocpp_voice=None, instructions="A warm British narrator") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") @@ -639,7 +670,7 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") - hub._convert_menu(None, + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) @@ -647,14 +678,14 @@ class ConvertFlowTests(unittest.TestCase): def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("Could not list models", self.tui.flashes[0]) def test_audiocpp_remote_empty_models_flash_and_abort(self): self._patch_remote([]) - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("hosts no model entries", self.tui.flashes[0]) @@ -668,7 +699,7 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="", instructions="") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) @@ -702,7 +733,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="Narrator", instructions="") - cmd = hub._convert_menu(None, + cmd = self._convert(None, [self._ready("audiocpp", "audio.cpp")]) self.assertEqual(queried, []) self.assertIsNotNone(cmd) @@ -728,7 +759,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_menu(None, [ + cmd = self._convert(None, [ self._ready("audiocpp", "audio.cpp"), self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[0], "convert") @@ -748,7 +779,7 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp", url="http://10.0.0.5:8080")]) self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8080") @@ -777,7 +808,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="qwen", mode="custom", speaker="Serena", clone="") with patch.object(hub.common, "update_config_value") as mk_update: - cmd = hub._convert_menu(None, + cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) speaker_in_memory = hub.config.SPEAKER self.assertEqual(cmd[0], "convert") @@ -809,7 +840,7 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="qwen", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") with patch.object(hub.common, "update_config_value") as mk_update: - cmd = hub._convert_menu(None, + cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") # Clone mode does not touch the global speaker. @@ -824,7 +855,7 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.faster_backend, "_checkout", return_value=Path(td)): self._answer_form(backend="faster", faster_voice="obama") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._ready("faster", "faster-qwen3-tts")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], "faster") @@ -839,7 +870,7 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.faster_backend, "_checkout", return_value=checkout): self._answer_form(backend="faster", faster_voice="obama") - cmd = hub._convert_menu( + cmd = self._convert( None, [self._ready("faster", "faster-qwen3-tts")]) self.assertEqual(cmd[2]["voice"], "obama") voice_field = self._field("faster_voice") @@ -851,7 +882,7 @@ class ConvertFlowTests(unittest.TestCase): st = self._remote("faster", "faster-qwen3-tts", url="http://10.0.0.5:8000") self._answer_form(backend="faster-remote", faster_voice="obama") - cmd = hub._convert_menu(None, [st]) + cmd = self._convert(None, [st]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], "faster") self.assertEqual(cmd[2]["voice"], "obama") @@ -869,7 +900,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "SPEAKER", "Vivian"): self._answer_form(backend="qwen-remote", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") - cmd = hub._convert_menu(None, [st]) + cmd = self._convert(None, [st]) self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") @@ -895,7 +926,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="qwen", mode="custom", speaker="Vivian", clone="") - cmd = hub._convert_menu(None, [ + cmd = self._convert(None, [ self._ready("audiocpp", "audio.cpp"), self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[0], "convert") @@ -1059,30 +1090,55 @@ class PreflightTests(unittest.TestCase): self.assertFalse(hub._preflight(stdscr, self._cmd())) mk_flash.assert_called_once() + def test_confirm_esc_raises_back_to_form(self): + # Esc on an overwrite confirm backs out to the form (one screen), + # not "No" — which could dump the user on the main menu. + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=(["book.txt"], [("book.txt", "x")])) \ + as mk_pre: + hub._preflight(stdscr, self._cmd()) + confirm = mk_pre.call_args.kwargs["confirm"] + with patch.object(hub.tui, "confirm", return_value=hub._CANCEL): + with self.assertRaises(hub._BackToForm): + confirm("overwrite?", True) + class DispatchConversionTests(unittest.TestCase): - """_dispatch_conversion: builds the config and runs the run view.""" + """_Hub._run_conversion: builds the config and runs the run view.""" + + def test_runs_run_view_on_the_hub_screen(self): + timeouts = [] - def test_runs_run_view_inside_curses(self): - from tests.test_tui import FakeScreen + class Screen: + def timeout(self, ms): + timeouts.append(ms) class FakeView: def __init__(self, scr, config): self.config = config + self.scr = scr def run(self): pass - made = [] + screen = Screen() with patch.object(hub, "_prepare_run_config", return_value=hub.runview.RunConfig( backend="qwen", backend_label="qwen-tts", kwargs={}, book_files=[], planned=[])) as mk_cfg, \ - patch("curses.wrapper", - side_effect=lambda cb: cb(FakeScreen())) as mk_wrapper, \ patch.object(hub.runview, "RunView", FakeView): - hub._dispatch_conversion("qwen", {}) + hub._Hub(screen)._run_conversion("qwen", {}) + mk_cfg.assert_called_once() + # The run view leaves a timed getch behind; it is reset so the hub + # menus block for keys again. + self.assertEqual(timeouts, [-1]) + + def test_no_run_config_skips_the_view(self): + with patch.object(hub, "_prepare_run_config", return_value=None) as mk_cfg, \ + patch.object(hub.runview, "RunView") as mk_view: + hub._Hub(None)._run_conversion("qwen", {}) mk_cfg.assert_called_once() - mk_wrapper.assert_called_once() + mk_view.assert_not_called() class AddAutostartTests(unittest.TestCase): @@ -1276,7 +1332,7 @@ class SettingsTests(unittest.TestCase): with patch.object(hub.tui, "form", fake_form), \ patch.object(hub, "_apply_settings", fake_apply), \ patch.object(hub.tui, "flash", fake_flash): - hub._settings_menu(None) + hub._Hub(None).screen_settings() self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", "chunk_size", "unload_models", "audiocpp_port", @@ -1324,7 +1380,7 @@ class SettingsTests(unittest.TestCase): with patch.object(hub.tui, "form", fake_form), \ patch.object(hub, "_apply_settings", fake_apply): - hub._settings_menu(None) + hub._Hub(None).screen_settings() self.assertEqual(applied, []) def test_settings_menu_writes_config_end_to_end(self): @@ -1372,7 +1428,7 @@ class SettingsTests(unittest.TestCase): FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10, 21, ord("3"), ord("0"), ord("0"), 10, 9, 10, 10]) - hub._settings_menu(screen) + hub._Hub(screen).screen_settings() text = path.read_text(encoding="utf-8") self.assertIn('AUDIO_FORMAT = "m4b"', text) self.assertIn("CHUNK_SIZE = 300", text) @@ -1456,37 +1512,39 @@ class AudiocppServerConfigTests(unittest.TestCase): class ConfigureBackendsDispatchTests(unittest.TestCase): - """run() and the configure-backends submenus dispatch their commands.""" + """The configure-backends screens dispatch their backend actions.""" - def test_run_dispatches_install_to_setup_tui(self): + def test_setup_screen_runs_the_backend_wizard_and_goes_back(self): info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(info, "setup_screen") as mk_setup: + result = hub._Hub(None).screen_setup(info)() + mk_setup.assert_called_once_with(None) + self.assertIs(result, tui.Wizard.BACK) - def fake_wrapper(cb): - fake_wrapper.calls += 1 - return ("install", "qwen") if fake_wrapper.calls == 1 else None - fake_wrapper.calls = 0 - - import curses - with patch.object(curses, "wrapper", fake_wrapper), \ - patch.object(hub, "get", return_value=info), \ - patch.object(info, "setup_tui") as mk_setup: - hub.run() - mk_setup.assert_called_once_with() - - def test_run_dispatches_uninstall(self): + def test_setup_screen_flashes_on_crash_and_goes_back(self): info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + flashes = [] - def fake_wrapper(cb): - fake_wrapper.calls += 1 - return ("uninstall", "qwen") if fake_wrapper.calls == 1 else None - fake_wrapper.calls = 0 + def boom(scr): + raise RuntimeError("kaboom") - import curses - with patch.object(curses, "wrapper", fake_wrapper), \ - patch.object(hub, "get", return_value=info), \ - patch.object(info, "uninstall") as mk_uninstall: - hub.run() + with patch.object(info, "setup_screen", boom), \ + patch.object(hub.tui, "flash", + lambda scr, text, kind="warn": + flashes.append(text)): + result = hub._Hub(None).screen_setup(info)() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(flashes, ["kaboom"]) + + def test_screen_uninstall_runs_uninstall_and_goes_back(self): + import contextlib + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + with patch.object(hub._Hub, "_pick_backend", return_value=info), \ + patch.object(info, "uninstall") as mk_uninstall, \ + patch.object(hub.tui, "suspend", contextlib.nullcontext): + result = hub._Hub(None).screen_uninstall() mk_uninstall.assert_called_once_with() + self.assertIs(result, tui.Wizard.BACK) def _capture_flashes(self): flashes = [] @@ -1570,12 +1628,12 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual(len(flashes), 1) self.assertEqual(flashes[0][1], "err") - def test_pick_backend_menu_install_lists_uninstalled_only(self): + def test_pick_backend_install_lists_uninstalled_only(self): captured = {} def fake_menu(stdscr, title, options, **kwargs): captured["options"] = options - return hub._GO_BACK + return tui.Wizard.BACK infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0), BackendInfo("faster", "faster-qwen3-tts", lambda: None, @@ -1585,14 +1643,14 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): BackendStatus("faster", "faster-qwen3-tts", 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): - result = hub._pick_backend_menu(None, statuses, "Install Backend", - installed_only=False) + result = hub._Hub(None)._pick_backend(installed_only=False) self.assertIsNone(result) self.assertEqual([label for label, _ in captured["options"]], ["faster-qwen3-tts"]) - def test_pick_backend_menu_uninstall_lists_installed_only(self): + def test_pick_backend_uninstall_lists_installed_only(self): captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -1607,12 +1665,106 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): BackendStatus("faster", "faster-qwen3-tts", installed=False, configured=False)] with patch.object(hub, "REGISTRY", infos), \ - patch.object(hub.tui, "menu", fake_menu): - result = hub._pick_backend_menu(None, statuses, "Uninstall Backend", - installed_only=True) - self.assertEqual(result, ("uninstall", "qwen")) + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub, "get", return_value=infos[0]) as mk_get: + result = hub._Hub(None)._pick_backend(installed_only=True) + self.assertEqual(result, infos[0]) + mk_get.assert_called_once_with("qwen") self.assertEqual([label for label, _ in captured["options"]], ["qwen-tts"]) + +class HubNavigationTests(unittest.TestCase): + """Esc (and q) steps back exactly one screen across the whole hub.""" + + def setUp(self): + tui._THEME.clear() + self.addCleanup(tui._THEME.clear) + + def _info(self, key="audiocpp", label="audio.cpp"): + return BackendInfo(key, label, lambda: None, lambda: 0) + + def _status(self, key="audiocpp", label="audio.cpp"): + return BackendStatus(key, label, installed=True, configured=True) + + def _drive(self, script, statuses, registry): + """Run the hub, feeding SCRIPT (one value per menu) to tui.menu. + + Records the title of every menu shown, in order. ``tui.Wizard.BACK`` + in the script simulates Esc on that menu. + """ + titles = [] + + def menu(stdscr, title, options, **kwargs): + titles.append(title) + return script.pop(0) + + def get(key): + return next((i for i in registry if i.key == key), None) + + with patch.object(hub, "REGISTRY", registry), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub, "get", side_effect=get), \ + patch.object(hub.tui, "menu", menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None): + hub._Hub(None).run() + return titles + + def test_esc_on_wizard_first_screen_returns_to_configure(self): + # The reported bug: Esc on the audio.cpp "Select TTS model + # families" tree (the wizard's first screen) must land back on + # "Configure backends", not the main menu. + info = self._info() + with patch.object(info, "setup_screen", return_value=1): + titles = self._drive( + ["configure_backends", ("configure", "audiocpp"), + tui.Wizard.BACK, tui.Wizard.BACK], + [self._status()], [info]) + self.assertEqual( + titles, + ["tts-audiobook-generator", "Configure backends", + "Configure backends", "tts-audiobook-generator"]) + + def test_esc_on_install_picker_returns_to_configure(self): + registry = [self._info("audiocpp", "audio.cpp"), + self._info("qwen", "qwen-tts")] + statuses = [self._status("audiocpp", "audio.cpp"), + BackendStatus("qwen", "qwen-tts", installed=False, + configured=False)] + titles = self._drive( + ["configure_backends", "install", tui.Wizard.BACK, + tui.Wizard.BACK, tui.Wizard.BACK], + statuses, registry) + self.assertEqual( + titles, + ["tts-audiobook-generator", "Configure backends", + "Install Backend", "Configure backends", + "tts-audiobook-generator"]) + + def test_esc_on_server_action_returns_one_screen_at_a_time(self): + specs = [ServerSpec("qwen-custom", "http://127.0.0.1:7860", []), + ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])] + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, servers=specs) + registry = [self._info("qwen", "qwen-tts")] + with patch.object(hub.common, "server_running", return_value=False): + titles = self._drive( + ["server", "qwen", "qwen-clone", tui.Wizard.BACK, + tui.Wizard.BACK, tui.Wizard.BACK, tui.Wizard.BACK], + [status], registry) + self.assertEqual( + titles, + ["tts-audiobook-generator", "Start / Stop a server", + "qwen-tts server", "qwen-clone (stopped)", + "qwen-tts server", "Start / Stop a server", + "tts-audiobook-generator"]) + + def test_esc_on_main_menu_quits(self): + titles = self._drive([tui.Wizard.BACK], [], []) + self.assertEqual(titles, ["tts-audiobook-generator"]) + + if __name__ == "__main__": - unittest.main() + unittest.main()
\ No newline at end of file |
