aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_audiobook_cli.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-31 19:45:57 -0400
committerhistoria <historiavg@proton.me>2026-08-31 19:45:57 -0400
commit10e72d4960e865acf5346ab8cf518ed5844fe45c (patch)
treeadf8c10386b9da6280c247f1fed137ef1a514157 /app/tests/test_audiobook_cli.py
parent4bd0282da65db9f118ef5250582ab67079fad538 (diff)
downloadtts-audiobook-generator-10e72d4960e865acf5346ab8cf518ed5844fe45c.tar.gz
feat: generate a book with all installed models to compare
Diffstat (limited to 'app/tests/test_audiobook_cli.py')
-rw-r--r--app/tests/test_audiobook_cli.py233
1 files changed, 233 insertions, 0 deletions
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
index a3f0b90..f5dddf4 100644
--- a/app/tests/test_audiobook_cli.py
+++ b/app/tests/test_audiobook_cli.py
@@ -16,6 +16,7 @@ import logging
import shutil
import sys
import tempfile
+import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -333,6 +334,238 @@ class ConvertWiringTests(unittest.TestCase):
self._convert(output_file=self.tmp / "dune.mp3")
+class AllModelsConvertTests(unittest.TestCase):
+ """convert(model_ids=...): the "All (multiple generation)" loop.
+
+ One AudiobookConverter per model, model-major, each forced to unload
+ previously-loaded server models (clean VRAM between models); a failed
+ book or a model that cannot start does not sink the remaining models;
+ the progress events are renumbered into one global book sequence
+ stamped with the generating model and one merged "done" is emitted.
+ """
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_all_"))
+ 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, *, run_results=None, make_run=None, progress=None,
+ cancel=None, planned_by_model="default", instances=None,
+ **kwargs):
+ """Run convert() with AudiobookConverter mocked per model.
+
+ RUN_RESULTS gives each model's converter.run() return value in
+ construction order; MAKE_RUN, when given, builds each instance's
+ run() from the constructor kwargs (for event-emitting fakes).
+ INSTANCES, when given, is a list the per-model instances are
+ appended to. Returns (code, class mock, per-model ctor kwargs).
+ """
+ kwargs.setdefault("backend", "audiocpp")
+ kwargs.setdefault("model_ids", ["m1", "m2"])
+ kwargs.setdefault("model_voices", {"m1": "narrator", "m2": None})
+ if planned_by_model == "default":
+ kwargs.setdefault("planned_by_model", {
+ "m1": [(self.book, "book_m1_narrator")],
+ "m2": [(self.book, "book_m2_none")]})
+ else:
+ kwargs["planned_by_model"] = planned_by_model
+ kwargs.setdefault("book_files", [self.book])
+ if progress is not None:
+ kwargs["progress"] = progress
+ if cancel is not None:
+ kwargs["cancel"] = cancel
+ states = list(run_results or [])
+ ctor_kwargs = []
+ made = instances if instances is not None else []
+
+ def make_instance(*args, **ckwargs):
+ ckwargs = dict(ckwargs)
+ ctor_kwargs.append(ckwargs)
+ inst = MagicMock()
+ made.append(inst)
+ if make_run is not None:
+ inst.run.side_effect = make_run(ckwargs)
+ else:
+ result = states[len(made) - 1] \
+ if len(made) <= len(states) else True
+ inst.run.return_value = result
+ return inst
+
+ fake_class = MagicMock(side_effect=make_instance)
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class):
+ code = audiobook.convert(**kwargs)
+ return code, fake_class, ctor_kwargs
+
+ def test_one_converter_per_model_with_its_own_voice(self):
+ # Model-major: every model runs its planned books before the next
+ # model starts, each with the voice the form adapted for it and a
+ # forced pre-run model unload (clean VRAM between models).
+ code, fake_class, ctors = self._convert()
+ self.assertEqual(code, 0)
+ self.assertEqual(fake_class.call_count, 2)
+ self.assertEqual(ctors[0]["model_id"], "m1")
+ self.assertEqual(ctors[0]["voice"], "narrator")
+ self.assertTrue(ctors[0]["unload_models"])
+ self.assertEqual(ctors[1]["model_id"], "m2")
+ self.assertIsNone(ctors[1]["voice"])
+ self.assertTrue(ctors[1]["unload_models"])
+
+ def test_planned_entries_reach_each_converter(self):
+ instances = []
+ self._convert(instances=instances)
+ self.assertEqual(instances[0]._planned,
+ [(self.book, "book_m1_narrator")])
+ self.assertEqual(instances[1]._planned,
+ [(self.book, "book_m2_none")])
+
+ def test_failed_model_does_not_sink_the_next(self):
+ # m1's conversion fails: the loop still constructs and runs m2,
+ # and the overall run reports failure (not every book succeeded).
+ code, fake_class, _ = self._convert(run_results=[False, True])
+ self.assertEqual(code, 1)
+ self.assertEqual(fake_class.call_count, 2)
+
+ def test_model_that_cannot_start_is_skipped(self):
+ # A connect-time failure (constructor raise) is reported and the
+ # remaining models still run.
+ started = []
+
+ def make_instance(*args, **ckwargs):
+ started.append(ckwargs["model_id"])
+ if ckwargs["model_id"] == "m1":
+ raise RuntimeError("voice 'x' is not available")
+ inst = MagicMock()
+ inst.run.return_value = True
+ return inst
+
+ fake_class = MagicMock(side_effect=make_instance)
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class), \
+ patch.object(logging_kit, "log_traceback"):
+ code = audiobook.convert(
+ backend="audiocpp", model_ids=["m1", "m2"],
+ model_voices={"m1": "narrator", "m2": None},
+ planned_by_model={"m1": [(self.book, "book_m1_narrator")],
+ "m2": [(self.book, "book_m2_none")]},
+ book_files=[self.book])
+ self.assertEqual(started, ["m1", "m2"])
+ self.assertEqual(code, 1)
+
+ def test_events_are_renumbered_and_stamped_with_the_model(self):
+ # Book events carry one global index across all models; done/cancel
+ # events from the per-model converters are swallowed and one merged
+ # "done" is emitted at the end.
+ events = []
+
+ def make_run(ckwargs):
+ emit = ckwargs["progress"]
+
+ def run():
+ emit({"kind": "book", "index": 1, "total": 1,
+ "name": "book.txt"})
+ emit({"kind": "chunks", "total": 3})
+ emit({"kind": "chunk_done", "chunk": 1, "total": 3})
+ emit({"kind": "book_done", "name": "book.txt", "ok": True,
+ "files": [f"book_{ckwargs['model_id']}.mp3"]})
+ emit({"kind": "done", "ok": 1, "total": 1})
+ return True
+ return run
+
+ code, _, _ = self._convert(progress=events.append,
+ make_run=make_run)
+ self.assertEqual(code, 0)
+ kinds = [e["kind"] for e in events]
+ self.assertEqual(kinds, ["book", "chunks", "chunk_done", "book_done",
+ "book", "chunks", "chunk_done", "book_done",
+ "done"])
+ self.assertEqual(events[0],
+ {"kind": "book", "index": 1, "total": 2,
+ "name": "book.txt", "model": "m1"})
+ self.assertEqual(events[4],
+ {"kind": "book", "index": 2, "total": 2,
+ "name": "book.txt", "model": "m2"})
+ self.assertEqual(events[3]["model"], "m1")
+ self.assertEqual(events[7]["model"], "m2")
+ self.assertEqual(events[8],
+ {"kind": "done", "ok": 2, "total": 2,
+ "cancelled": False})
+
+ def test_cancellation_stops_the_remaining_models(self):
+ # m1's run sets the cancel event: the loop stops before m2 and the
+ # merged done reports the cancellation.
+ cancel = threading.Event()
+ events = []
+
+ def make_run(ckwargs):
+ emit = ckwargs["progress"]
+
+ def run():
+ emit({"kind": "book", "index": 1, "total": 1,
+ "name": "book.txt"})
+ emit({"kind": "book_done", "name": "book.txt", "ok": True,
+ "files": ["book_m1_narrator.mp3"]})
+ cancel.set()
+ return False
+ return run
+
+ code, fake_class, _ = self._convert(progress=events.append,
+ make_run=make_run, cancel=cancel)
+ self.assertEqual(fake_class.call_count, 1)
+ self.assertEqual(code, 1)
+ self.assertEqual(events[-1],
+ {"kind": "done", "ok": 1, "total": 2,
+ "cancelled": True})
+
+ def test_plans_are_computed_when_not_provided(self):
+ # Without planned_by_model (a scripted call) each model plans its
+ # own model-tagged outputs, with its own voice for the narrator
+ # tag the overwrite questions are asked about.
+ preflight = MagicMock(return_value=([self.book],
+ [(self.book, "dune")]))
+ fake_class = MagicMock()
+ fake_class.preflight_overwrites = preflight
+ # The converter class is mocked wholesale; the name-tag helper is
+ # a pure static method, so stand in the real behavior.
+ fake_class.compute_model_tag = staticmethod(
+ AudiobookConverter.compute_model_tag)
+
+ def make_instance(*args, **ckwargs):
+ inst = MagicMock()
+ inst.run.return_value = True
+ return inst
+ fake_class.side_effect = make_instance
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class):
+ code = audiobook.convert(
+ backend="audiocpp", model_ids=["m1", "m2"],
+ model_voices={"m1": "narrator", "m2": None},
+ book_files=None)
+ self.assertEqual(code, 0)
+ self.assertEqual(preflight.call_count, 2)
+ first, second = preflight.call_args_list
+ self.assertEqual(first.kwargs["voice"], "narrator")
+ self.assertEqual(first.kwargs["name_tag"], "m1")
+ self.assertEqual(second.kwargs["voice"], None)
+ self.assertEqual(second.kwargs["name_tag"], "m2")
+
+ def test_nothing_planned_is_a_clean_noop(self):
+ code, fake_class, _ = self._convert(
+ planned_by_model={"m1": [], "m2": []})
+ self.assertEqual(code, 0)
+ fake_class.assert_not_called()
+
+
class ManagedServerWiringTests(unittest.TestCase):
"""convert(manage_server=True) boots and stops the server around the run.