aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_audiobook_cli.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/tests/test_audiobook_cli.py')
-rw-r--r--app/tests/test_audiobook_cli.py312
1 files changed, 312 insertions, 0 deletions
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
new file mode 100644
index 0000000..e4b558b
--- /dev/null
+++ b/app/tests/test_audiobook_cli.py
@@ -0,0 +1,312 @@
+"""Tests for the audiobook.py CLI — single-book flags and arg validation.
+
+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.
+"""
+
+import contextlib
+import io
+import shutil
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+# audiobook.py sits at the repo root, two levels above this test module.
+REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+import audiobook # noqa: E402
+from converter import config # noqa: E402
+from converter import converter as converter_mod # noqa: E402
+from converter.converter import AudiobookConverter # noqa: E402
+
+
+def _make_book(tmp: Path, name: str = "dune.txt") -> Path:
+ book = tmp / name
+ book.write_text("A beginning is a very delicate time.", encoding="utf-8")
+ return book
+
+
+class MainTestCase(unittest.TestCase):
+ """Base: run audiobook.main() with argv, bootstrap stubbed, convert mocked."""
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_cli_"))
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+
+ def run_main(self, argv):
+ """Run main() with the given argv; returns (code, stderr, convert mock).
+
+ The envs bootstrap (which re-execs into the managed venv via
+ os.execv when active) and convert() are stubbed, so no TTS work
+ happens and the process survives.
+ """
+ err = io.StringIO()
+ convert = MagicMock(return_value=0)
+ with patch.object(sys, "argv", ["audiobook.py", *argv]), \
+ contextlib.redirect_stderr(err), \
+ patch.object(audiobook._envs, "bootstrap"), \
+ patch.object(audiobook, "convert", convert):
+ try:
+ audiobook.main()
+ code = None
+ except SystemExit as exc:
+ code = exc.code
+ return code, err.getvalue(), convert
+
+
+class MainFlagConflictTests(MainTestCase):
+ """Mixing the directory and single-book flag pairs stops with an error."""
+
+ def test_input_and_input_file_conflict(self):
+ code, err, convert = self.run_main(
+ ["--input", str(self.tmp), "--input-file", str(self.tmp / "dune.txt")])
+ self.assertEqual(code, 2)
+ self.assertIn("--input", err)
+ self.assertIn("--input-file", err)
+ self.assertIn("cannot be used together", err)
+ convert.assert_not_called()
+
+ def test_output_and_output_file_conflict(self):
+ code, err, convert = self.run_main(
+ ["--output", str(self.tmp / "out"),
+ "--output-file", str(self.tmp / "out" / "dune.mp3")])
+ self.assertEqual(code, 2)
+ self.assertIn("--output", err)
+ self.assertIn("--output-file", err)
+ self.assertIn("cannot be used together", err)
+ convert.assert_not_called()
+
+ def test_output_file_requires_input_file(self):
+ code, err, convert = self.run_main(
+ ["--output-file", str(self.tmp / "dune.mp3")])
+ self.assertEqual(code, 2)
+ self.assertIn("--output-file", err)
+ self.assertIn("--input-file", err)
+ convert.assert_not_called()
+
+ def test_conflict_wins_over_bad_directory(self):
+ # The flag explanation fires even when --input is also invalid.
+ code, err, convert = self.run_main(
+ ["--input", str(self.tmp / "nope"),
+ "--input-file", str(self.tmp / "dune.txt")])
+ self.assertEqual(code, 2)
+ self.assertIn("cannot be used together", err)
+ convert.assert_not_called()
+
+
+class MainPathValidationTests(MainTestCase):
+ """--input-file/--output-file values are validated before converting."""
+
+ def test_missing_input_file(self):
+ code, err, convert = self.run_main(
+ ["--input-file", str(self.tmp / "nope.txt")])
+ self.assertEqual(code, 2)
+ self.assertIn("no such book file", err)
+ convert.assert_not_called()
+
+ def test_unsupported_input_file_format(self):
+ book = _make_book(self.tmp, "dune.docx")
+ code, err, convert = self.run_main(["--input-file", str(book)])
+ self.assertEqual(code, 2)
+ self.assertIn("unsupported book format", err)
+ self.assertIn(".docx", err)
+ convert.assert_not_called()
+
+ def test_output_file_extension_mismatch_stops_the_run(self):
+ book = _make_book(self.tmp)
+ code, err, convert = self.run_main(
+ ["--input-file", str(book),
+ "--output-file", str(self.tmp / "dune.mp3"),
+ "--format", "m4b"])
+ self.assertEqual(code, 2)
+ self.assertIn("does not match the output format", err)
+ self.assertIn("--format mp3", err)
+ convert.assert_not_called()
+
+ def test_output_file_unsupported_extension(self):
+ book = _make_book(self.tmp)
+ code, err, convert = self.run_main(
+ ["--input-file", str(book),
+ "--output-file", str(self.tmp / "dune.xyz")])
+ self.assertEqual(code, 2)
+ self.assertIn("unsupported extension", err)
+ convert.assert_not_called()
+
+
+class MainHappyPathTests(MainTestCase):
+ """Valid single-book flags reach convert() resolved and typed."""
+
+ def test_input_and_output_file_forwarded(self):
+ book = _make_book(self.tmp)
+ target = self.tmp / "out" / "dune.mp3"
+ code, _, convert = self.run_main(
+ ["--input-file", str(book), "--output-file", str(target),
+ "--format", "mp3"])
+ self.assertEqual(code, 0)
+ convert.assert_called_once()
+ kwargs = convert.call_args.kwargs
+ self.assertEqual(kwargs["input_file"], book)
+ self.assertEqual(kwargs["output_file"], target)
+ self.assertEqual(kwargs["output_format"], "mp3")
+ self.assertIsNone(kwargs["input_dir"])
+ self.assertIsNone(kwargs["output_dir"])
+
+ def test_input_file_alone_keeps_output_defaults(self):
+ book = _make_book(self.tmp)
+ code, _, convert = self.run_main(["--input-file", str(book)])
+ self.assertEqual(code, 0)
+ kwargs = convert.call_args.kwargs
+ self.assertEqual(kwargs["input_file"], book)
+ self.assertIsNone(kwargs["output_file"])
+ self.assertEqual(kwargs["output_format"], config.AUDIO_FORMAT)
+
+ def test_directory_flags_still_forwarded(self):
+ out = self.tmp / "out"
+ code, _, convert = self.run_main(
+ ["--input", str(self.tmp), "--output", str(out)])
+ self.assertEqual(code, 0)
+ kwargs = convert.call_args.kwargs
+ self.assertEqual(kwargs["input_dir"], self.tmp)
+ self.assertEqual(kwargs["output_dir"], out)
+ self.assertIsNone(kwargs["input_file"])
+ self.assertIsNone(kwargs["output_file"])
+
+
+class ConvertWiringTests(unittest.TestCase):
+ """convert() turns the single-book flags into the pre-flight overrides."""
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_wiring_"))
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.book = _make_book(self.tmp)
+ # convert() repoints the converter module's folder globals; restore
+ # them so other tests keep seeing the configured folders.
+ 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, **kwargs):
+ preflight = MagicMock(
+ return_value=([self.book], [(self.book, "dune")]))
+ fake_instance = MagicMock()
+ fake_instance.run.return_value = True
+ fake_class = MagicMock(return_value=fake_instance)
+ fake_class.preflight_overwrites = preflight
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class):
+ code = audiobook.convert(**kwargs)
+ return code, preflight, fake_class, fake_instance
+
+ def test_output_file_redirects_folder_and_names_the_book(self):
+ out = self.tmp / "out"
+ code, preflight, _, fake_instance = self._convert(
+ input_file=self.book, output_file=out / "dune.mp3",
+ output_format="mp3")
+ self.assertEqual(code, 0)
+ self.assertEqual(converter_mod.AUDIOBOOKS_FOLDER, out)
+ self.assertEqual(preflight.call_args.kwargs["book_files"], [self.book])
+ self.assertEqual(preflight.call_args.kwargs["output_name"], "dune")
+ self.assertEqual(fake_instance._book_files, [self.book])
+ self.assertEqual(fake_instance._planned, [(self.book, "dune")])
+
+ def test_output_file_without_extension_uses_stem(self):
+ out = self.tmp / "out"
+ _, preflight, _, _ = self._convert(
+ input_file=self.book, output_file=out / "dune")
+ self.assertEqual(preflight.call_args.kwargs["output_name"], "dune")
+
+ def test_input_file_alone_keeps_output_folder_and_tagged_name(self):
+ _, preflight, _, _ = self._convert(input_file=self.book)
+ self.assertEqual(preflight.call_args.kwargs["book_files"],
+ [self.book])
+ self.assertIsNone(preflight.call_args.kwargs["output_name"])
+ self.assertEqual(
+ converter_mod.AUDIOBOOKS_FOLDER,
+ converter_mod.resolve_dir(config.OUTPUT_DIR, "output"))
+
+ def test_output_file_requires_input_file(self):
+ with self.assertRaises(ValueError):
+ self._convert(output_file=self.tmp / "dune.mp3")
+
+
+class PreflightOverrideTests(unittest.TestCase):
+ """preflight_overwrites honors the explicit book list and output name."""
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_preflight_"))
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.book = _make_book(self.tmp)
+ # The overwrite check globs AUDIOBOOKS_FOLDER; point it at the
+ # temporary folder so the repo's real output dir stays untouched.
+ patcher = patch.object(converter_mod, "AUDIOBOOKS_FOLDER", self.tmp)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def _preflight(self, **kwargs):
+ options = dict(backend="audiocpp", voice="Vivian",
+ voice_mode="custom", voice_clone_ref_audio=None,
+ output_format="mp3")
+ options.update(kwargs)
+ return AudiobookConverter.preflight_overwrites(**options)
+
+ def test_explicit_book_and_output_name_used_verbatim(self):
+ book_files, planned = self._preflight(book_files=[self.book],
+ output_name="dune")
+ self.assertEqual(book_files, [self.book])
+ self.assertEqual(planned, [(self.book, "dune")])
+
+ def test_explicit_output_name_skips_narrator_tag(self):
+ _, planned = self._preflight(book_files=[self.book],
+ output_name="dune")
+ # A directory scan would append the narrator tag (dune_Vivian).
+ self.assertNotIn("Vivian", planned[0][1])
+
+ def test_unsupported_books_filtered_from_explicit_list(self):
+ stray = self.tmp / "notes.docx"
+ stray.write_text("nope", encoding="utf-8")
+ book_files, planned = self._preflight(
+ book_files=[self.book, stray], output_name="dune")
+ self.assertEqual(book_files, [self.book])
+ self.assertEqual(planned, [(self.book, "dune")])
+
+ def test_declined_overwrite_yields_empty_planned(self):
+ existing = self.tmp / "dune.mp3"
+ existing.write_bytes(b"prior audio")
+ with patch.object(converter_mod, "prompt_overwrite",
+ return_value=False):
+ book_files, planned = self._preflight(book_files=[self.book],
+ output_name="dune")
+ self.assertEqual(book_files, [self.book])
+ self.assertEqual(planned, [])
+
+ def test_accepted_overwrite_plans_the_book(self):
+ existing = self.tmp / "dune.mp3"
+ existing.write_bytes(b"prior audio")
+ with patch.object(converter_mod, "prompt_overwrite",
+ return_value=True):
+ book_files, planned = self._preflight(book_files=[self.book],
+ output_name="dune")
+ self.assertEqual(planned, [(self.book, "dune")])
+
+ def test_empty_explicit_list_nothing_to_convert(self):
+ book_files, planned = self._preflight(book_files=[],
+ output_name="dune")
+ self.assertEqual((book_files, planned), ([], []))
+
+
+if __name__ == "__main__":
+ unittest.main()