diff options
| author | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
| commit | 10e72d4960e865acf5346ab8cf518ed5844fe45c (patch) | |
| tree | adf8c10386b9da6280c247f1fed137ef1a514157 /app/tests/test_audiobook_cli.py | |
| parent | 4bd0282da65db9f118ef5250582ab67079fad538 (diff) | |
| download | tts-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.py | 233 |
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. |
