aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 20:17:11 -0400
committerhistoria <historiavg@proton.me>2026-08-24 20:17:11 -0400
commitcf24fa74188cee498eeb7b94422371c952278d4a (patch)
tree302244a8b09b9173fbde542874cfa549f26be7a2 /app/tests/test_hub.py
parent0522e73b68291af62e43c387ab8f9b8ffa2cab47 (diff)
downloadtts-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.py350
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