aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_audiobook_cli.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 17:16:54 -0400
committerhistoria <historiavg@proton.me>2026-08-28 17:16:54 -0400
commite66eb0e7d4342ae1c58e9bbd341843753be548f0 (patch)
tree0ade3b71c86b59511d7653e450377a9366a30482 /app/tests/test_audiobook_cli.py
parent270fa60c01866c4431d540be960b6cd2bc2b9c44 (diff)
downloadtts-audiobook-generator-e66eb0e7d4342ae1c58e9bbd341843753be548f0.tar.gz
feat: cli auto-starts and stops locally-managed servers if no --api-url is passed
Diffstat (limited to 'app/tests/test_audiobook_cli.py')
-rw-r--r--app/tests/test_audiobook_cli.py135
1 files changed, 131 insertions, 4 deletions
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
index d947c5d..f13cf9e 100644
--- a/app/tests/test_audiobook_cli.py
+++ b/app/tests/test_audiobook_cli.py
@@ -1,12 +1,13 @@
-"""Tests for the audiobook.py CLI — single-book flags and arg validation.
+"""Tests for the audiobook.py CLI — single-book flags, arg validation, and
+the managed-server wiring.
audiobook.py lives at the repo root (one level above app/), so the tests
bootstrap the root onto sys.path to import it. main() runs with the
managed-environment bootstrap stubbed (it would otherwise re-exec the
process into envs/tts) and convert() mocked, asserting only argparse
-behavior and what reaches convert(); convert()'s single-book wiring and
-the pre-flight overrides are tested against the real functions with
-temporary directories.
+behavior and what reaches convert(); convert()'s single-book wiring, the
+pre-flight overrides, and the manage_server lifecycle are tested against
+the real functions with temporary directories.
"""
import contextlib
@@ -250,6 +251,23 @@ class MainHappyPathTests(MainTestCase):
self.assertIsNone(kwargs["input_file"])
self.assertIsNone(kwargs["output_file"])
+ def test_manage_server_requested_without_api_url(self):
+ # Without --api-url the CLI asks convert() to manage the server
+ # lifecycle (convert() performs the actual boot/stop).
+ code, _, convert = self.run_main([])
+ self.assertEqual(code, 0)
+ self.assertIs(convert.call_args.kwargs["manage_server"], True)
+
+ def test_api_url_run_still_carries_the_manage_flag(self):
+ # The flag travels too; convert() itself skips management when an
+ # explicit api_url targets an external server.
+ code, _, convert = self.run_main(
+ ["--api-url", "10.20.30.40:8080"])
+ self.assertEqual(code, 0)
+ self.assertEqual(convert.call_args.kwargs["api_url"],
+ "http://10.20.30.40:8080")
+ self.assertIs(convert.call_args.kwargs["manage_server"], True)
+
class ConvertWiringTests(unittest.TestCase):
"""convert() turns the single-book flags into the pre-flight overrides."""
@@ -314,6 +332,115 @@ class ConvertWiringTests(unittest.TestCase):
self._convert(output_file=self.tmp / "dune.mp3")
+class ManagedServerWiringTests(unittest.TestCase):
+ """convert(manage_server=True) boots and stops the server around the run.
+
+ The lifecycle decisions live in backends.managed (tested there); these
+ pin convert()'s wiring: when the boot happens relative to pre-flight
+ and the conversion, that shutdown runs even on failure or Ctrl-C, and
+ that an external api_url or the hub's progress-callback path never
+ touch the server.
+ """
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_managed_"))
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.book = _make_book(self.tmp)
+ self._old_folders = (converter_mod.BOOKS_FOLDER,
+ converter_mod.AUDIOBOOKS_FOLDER)
+ self.addCleanup(self._restore_folders)
+
+ def _restore_folders(self):
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \
+ self._old_folders
+
+ def _convert(self, *, server_ok=True, run_result=True, run_raises=None,
+ preflight_result=None, **kwargs):
+ """Run convert() with the managed-server and converter mocked.
+
+ Returns (code, ensure mock, server mock, events, preflight mock);
+ EVENTS records the order of ensure_running / run / shutdown.
+ """
+ kwargs.setdefault("backend", "audiocpp")
+ kwargs.setdefault("manage_server", True)
+ if preflight_result is None:
+ preflight_result = ([self.book], [(self.book, "dune")])
+ preflight = MagicMock(return_value=preflight_result)
+ fake_instance = MagicMock()
+ events = []
+
+ def _run():
+ events.append("run")
+ if run_raises is not None:
+ raise run_raises
+ return run_result
+ fake_instance.run.side_effect = _run
+ fake_class = MagicMock(return_value=fake_instance)
+ fake_class.preflight_overwrites = preflight
+ server = MagicMock()
+ server.ok = server_ok
+ server.shutdown.side_effect = lambda: events.append("shutdown")
+ ensure = MagicMock(return_value=server)
+
+ def _ensure(backend, voice_mode):
+ events.append(("ensure", backend, voice_mode))
+ return server
+ ensure.side_effect = _ensure
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class), \
+ patch("backends.managed.ensure_running", ensure):
+ code = audiobook.convert(**kwargs)
+ return code, ensure, server, events, preflight
+
+ def test_server_boots_before_the_run_and_stops_after(self):
+ code, ensure, _, events, _ = self._convert()
+ self.assertEqual(code, 0)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_qwen_run_needs_its_voice_mode_model(self):
+ _, ensure, _, events, _ = self._convert(
+ backend="qwen", clone="ref.wav")
+ self.assertEqual(events[0], ("ensure", "qwen", "voice_clone"))
+
+ def test_not_ok_boot_stops_before_converting(self):
+ code, ensure, server, events, _ = self._convert(server_ok=False)
+ self.assertEqual(code, 1)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "shutdown"])
+
+ def test_shutdown_runs_when_the_conversion_fails(self):
+ code, _, _, events, _ = self._convert(
+ run_raises=RuntimeError("server unreachable"))
+ self.assertEqual(code, 1)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_shutdown_runs_on_ctrl_c(self):
+ code, _, _, events, _ = self._convert(run_raises=KeyboardInterrupt)
+ self.assertEqual(code, 130)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_no_management_for_an_explicit_api_url(self):
+ code, ensure, _, _, _ = self._convert(
+ api_url="http://10.20.30.40:8080")
+ self.assertEqual(code, 0)
+ ensure.assert_not_called()
+
+ def test_no_management_for_the_hub_path(self):
+ # The run view boots/stops the server itself: manage_server False.
+ _, ensure, _, _, _ = self._convert(manage_server=False)
+ ensure.assert_not_called()
+
+ def test_no_server_boot_when_nothing_to_convert(self):
+ code, ensure, _, _, preflight = self._convert(
+ preflight_result=([], []))
+ self.assertEqual(code, 0)
+ ensure.assert_not_called()
+
+
class PreflightOverrideTests(unittest.TestCase):
"""preflight_overwrites honors the explicit book list and output name."""