aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
committerhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
commitf00249db9d1ea051d29aa1bcca869fc4b88e83eb (patch)
treea75f076fac1b63e0b4bf2eb8f54affbcc681a891 /tests
parent9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (diff)
downloadtts-audiobook-generator-f00249db9d1ea051d29aa1bcca869fc4b88e83eb.tar.gz
refactor: add app directory, dir structure change
Diffstat (limited to 'tests')
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/cover_test.pngbin6801 -> 0 bytes
-rw-r--r--tests/gen_test_cover.py8
-rw-r--r--tests/test_audio.py524
-rw-r--r--tests/test_backends.py178
-rw-r--r--tests/test_backends_audiocpp.py1062
-rw-r--r--tests/test_backends_envs.py204
-rw-r--r--tests/test_backends_faster.py172
-rw-r--r--tests/test_backends_servers.py146
-rw-r--r--tests/test_chunking.py118
-rw-r--r--tests/test_cleaning.py54
-rw-r--r--tests/test_converter.py619
-rw-r--r--tests/test_cover.py189
-rw-r--r--tests/test_extractors.py213
-rw-r--r--tests/test_hub.py502
-rw-r--r--tests/test_tts.py1513
-rw-r--r--tests/test_tui.py661
17 files changed, 0 insertions, 6163 deletions
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/tests/__init__.py
+++ /dev/null
diff --git a/tests/cover_test.png b/tests/cover_test.png
deleted file mode 100644
index 0c252db..0000000
--- a/tests/cover_test.png
+++ /dev/null
Binary files differ
diff --git a/tests/gen_test_cover.py b/tests/gen_test_cover.py
deleted file mode 100644
index 292469a..0000000
--- a/tests/gen_test_cover.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import sys
-from pathlib import Path
-sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-from converter.cover import generate_cover
-
-p = generate_cover('The Count of Monte Cristo',
- Path(__file__).resolve().parent / 'cover_test.png')
-print('written:', p)
diff --git a/tests/test_audio.py b/tests/test_audio.py
deleted file mode 100644
index ef5e92a..0000000
--- a/tests/test_audio.py
+++ /dev/null
@@ -1,524 +0,0 @@
-"""Tests for audio helpers: speed parameters, chunk cleanup, encoding,
-command construction, duration verification, and audio concatenation."""
-
-import io
-import tempfile
-import unittest
-import wave
-from contextlib import redirect_stdout
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from converter import audio
-from converter import config
-from converter.audio import (
- TrackMeta,
- _collect_chunk_files,
- _cover_args,
- _encode_args,
- _tag_args,
- build_concat_command,
- build_ffmetadata,
- build_m4b_chapters_command,
- cleanup_chunks,
- concat_audio_files,
- speed_export_params,
- verify_output_duration,
-)
-
-
-class SpeedExportParamsTests(unittest.TestCase):
- def test_normal_speed_no_filter(self):
- self.assertEqual(speed_export_params(1.0), [])
-
- def test_simple_speedup(self):
- self.assertEqual(speed_export_params(1.5), ["-filter:a", "atempo=1.5"])
-
- def test_simple_slowdown(self):
- self.assertEqual(speed_export_params(0.75), ["-filter:a", "atempo=0.75"])
-
- def test_chained_speedup_beyond_2x(self):
- self.assertEqual(speed_export_params(3.0), ["-filter:a", "atempo=2.0,atempo=1.5"])
-
- def test_chained_slowdown_below_half(self):
- self.assertEqual(speed_export_params(0.25), ["-filter:a", "atempo=0.5,atempo=0.5"])
-
- def test_zero_speed_rejected(self):
- with self.assertRaises(ValueError):
- speed_export_params(0)
-
- def test_negative_speed_rejected(self):
- with self.assertRaises(ValueError):
- speed_export_params(-1.5)
-
-
-class CleanupChunksTests(unittest.TestCase):
- def test_removes_only_chunk_files(self):
- with tempfile.TemporaryDirectory() as tmp:
- chunks_dir = Path(tmp)
- (chunks_dir / "chunk_0001.wav").write_bytes(b"stale")
- (chunks_dir / "chunk_0002.wav").write_bytes(b"stale")
- (chunks_dir / "keep.txt").write_bytes(b"keep")
-
- original = audio.CHUNKS_FOLDER
- audio.CHUNKS_FOLDER = chunks_dir
- try:
- cleanup_chunks()
- finally:
- audio.CHUNKS_FOLDER = original
-
- self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
- self.assertFalse((chunks_dir / "chunk_0002.wav").exists())
- self.assertTrue((chunks_dir / "keep.txt").exists())
-
- def test_removes_chapter_files(self):
- with tempfile.TemporaryDirectory() as tmp:
- chunks_dir = Path(tmp)
- (chunks_dir / "chapter_0001.m4b").write_bytes(b"stale")
- (chunks_dir / "chunk_0001.wav").write_bytes(b"stale")
-
- original = audio.CHUNKS_FOLDER
- audio.CHUNKS_FOLDER = chunks_dir
- try:
- cleanup_chunks()
- finally:
- audio.CHUNKS_FOLDER = original
-
- self.assertFalse((chunks_dir / "chapter_0001.m4b").exists())
- self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
-
-
-class EncodeArgsTests(unittest.TestCase):
- def test_mp3_uses_bitrate_only(self):
- self.assertEqual(_encode_args("mp3"), ["-b:a", config.AUDIO_BITRATE])
-
- def test_m4b_uses_aac(self):
- self.assertEqual(_encode_args("m4b"), ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE])
-
- def test_wav_is_lossless_pcm(self):
- self.assertEqual(_encode_args("wav"), ["-c:a", "pcm_s16le"])
-
- def test_ogg_uses_libvorbis(self):
- self.assertEqual(_encode_args("ogg"), ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE])
-
- def test_flac_is_lossless(self):
- self.assertEqual(_encode_args("flac"), ["-c:a", "flac"])
-
-
-class M4bContainerArgsTests(unittest.TestCase):
- def setUp(self):
- self._original = audio._brand_supported
- audio._brand_supported = True
-
- def tearDown(self):
- audio._brand_supported = self._original
-
- def test_includes_faststart_and_brand(self):
- args = audio._m4b_container_args()
- self.assertIn("+faststart", args)
- self.assertIn("M4B ", args)
-
- def test_brand_omitted_when_unsupported(self):
- audio._brand_supported = False
- self.assertEqual(audio._m4b_container_args(), ["-movflags", "+faststart"])
-
-
-class BuildConcatCommandTests(unittest.TestCase):
- def setUp(self):
- self._original = audio._brand_supported
- audio._brand_supported = True
-
- def tearDown(self):
- audio._brand_supported = self._original
-
- def test_mp3_has_no_container_flags(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3")
- self.assertEqual(cmd[:6], ["ffmpeg", "-y", "-f", "concat", "-safe", "0"])
- self.assertNotIn("-movflags", cmd)
- self.assertEqual(cmd[-1], "out.mp3")
-
- def test_m4b_gets_faststart_and_brand(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b")
- self.assertIn("+faststart", cmd)
- self.assertIn("M4B ", cmd)
- self.assertEqual(cmd[-1], "out.m4b")
-
- def test_speed_copy_writes_two_outputs(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b",
- speed=1.5, speed_path=Path("out_1.5.m4b"))
- self.assertIn("out.m4b", cmd)
- self.assertIn("out_1.5.m4b", cmd)
- # faststart must apply to both outputs
- self.assertEqual(cmd.count("+faststart"), 2)
- self.assertTrue(any("atempo=1.5" in arg for arg in cmd))
-
- def test_wav_intermediate(self):
- cmd = build_concat_command(Path("list.txt"), Path("chapter.wav"), "wav")
- self.assertIn("pcm_s16le", cmd)
- self.assertNotIn("-movflags", cmd)
-
- def test_speed_without_speed_path_rejected(self):
- with self.assertRaises(ValueError):
- build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", speed=1.5)
-
-
-class CollectChunkFilesTests(unittest.TestCase):
- def test_uses_recorded_paths_exactly(self):
- with tempfile.TemporaryDirectory() as tmp:
- present = Path(tmp) / "chunk_0001.wav"
- present.write_bytes(b"audio")
- chunk_results = {
- 1: present,
- 2: None, # failed chunk
- 3: Path(tmp) / "chunk_0003.wav", # recorded but deleted
- }
- files, missing = _collect_chunk_files(3, chunk_results)
-
- self.assertEqual(files, [present])
- self.assertEqual(missing, [2, 3])
-
-
-class BuildM4bChaptersCommandTests(unittest.TestCase):
- def setUp(self):
- self._original = audio._brand_supported
- audio._brand_supported = True
-
- def tearDown(self):
- audio._brand_supported = self._original
-
- def test_base_output_maps_metadata_and_chapters(self):
- cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"), Path("out.m4b"))
- self.assertIn("-map_metadata", cmd)
- self.assertIn("-map_chapters", cmd)
- self.assertIn("out.m4b", cmd)
- self.assertIn("+faststart", cmd)
- self.assertNotIn("filter_complex", cmd)
-
- def test_speed_outputs_get_their_own_chapter_metadata(self):
- cmd = build_m4b_chapters_command(
- Path("list.txt"), Path("meta.txt"), Path("out.m4b"),
- speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"),
- )
- self.assertEqual(cmd.count("+faststart"), 2)
- self.assertEqual(cmd.count("-map_chapters"), 2)
- self.assertTrue(any("atempo=2" in arg for arg in cmd))
- # base output chapters come from metadata input 1, speed copy from 2
- chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"]
- self.assertEqual(cmd[chapter_flags[0] + 1], "1")
- self.assertEqual(cmd[chapter_flags[1] + 1], "2")
- base_idx, speed_idx = cmd.index("out.m4b"), cmd.index("out_2.m4b")
- self.assertLess(chapter_flags[0], base_idx)
- self.assertGreater(chapter_flags[1], base_idx)
- self.assertLess(chapter_flags[1], speed_idx)
-
-
-class VerifyOutputDurationTests(unittest.TestCase):
- def _patch_probe(self, ms):
- audio.probe_duration_ms = lambda path: ms
-
- def setUp(self):
- self._original_probe = audio.probe_duration_ms
-
- def tearDown(self):
- audio.probe_duration_ms = self._original_probe
-
- def test_close_duration_passes(self):
- self._patch_probe(100_000)
- self.assertTrue(verify_output_duration(Path("x.m4b"), 101_000))
-
- def test_unverifiable_duration_passes(self):
- self._patch_probe(0)
- self.assertTrue(verify_output_duration(Path("x.m4b"), 100_000))
-
- def test_zero_expected_passes(self):
- self._patch_probe(50_000)
- self.assertTrue(verify_output_duration(Path("x.m4b"), 0))
-
- def test_large_drift_fails(self):
- self._patch_probe(3_600_000) # bogus "1 hour" for a 1 minute book
- with self.assertLogs(level="ERROR"):
- self.assertFalse(verify_output_duration(Path("x.m4b"), 60_000))
-
-
-class BuildFFMetadataTests(unittest.TestCase):
- def test_writes_chapters(self):
- chapters = [(0, 1200, "One"), (1200, 2500, "Two")]
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "meta.txt"
- build_ffmetadata(chapters, path)
- content = path.read_text(encoding="utf-8")
-
- self.assertTrue(content.startswith(";FFMETADATA1\n"))
- self.assertIn("[CHAPTER]", content)
- self.assertIn("TIMEBASE=1/1000", content)
- self.assertIn("START=0", content)
- self.assertIn("END=1200", content)
- self.assertIn("title=One", content)
- self.assertIn("START=1200", content)
- self.assertIn("title=Two", content)
-
- def test_escapes_special_characters(self):
- # ffmpeg's FFMETADATA format treats = ; # and \ as structural.
- chapters = [(0, 1000, "A = B; C# D\\E")]
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "meta.txt"
- build_ffmetadata(chapters, path)
- content = path.read_text(encoding="utf-8")
-
- self.assertIn(r"title=A \= B\; C\# D\\E", content)
-
- def test_collapses_newlines_in_titles(self):
- chapters = [(0, 1000, "Two\nLines")]
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "meta.txt"
- build_ffmetadata(chapters, path)
- content = path.read_text(encoding="utf-8")
-
- self.assertIn("title=Two Lines\n", content)
- self.assertNotIn("title=Two\n", content)
-
-
-class TagArgsTests(unittest.TestCase):
- META = TrackMeta(title="Dune", artist="Frank Herbert", album="Dune",
- track=2, total_tracks=5)
-
- def test_full_meta_written(self):
- args = _tag_args(self.META, "mp3")
- for pair in ("title=Dune", "artist=Frank Herbert",
- "album=Dune", "track=2/5"):
- self.assertIn(pair, args)
-
- def test_mp3_gets_id3v23(self):
- mp3_args = _tag_args(self.META, "mp3")
- self.assertIn("-id3v2_version", mp3_args)
- self.assertEqual(mp3_args[mp3_args.index("-id3v2_version") + 1], "3")
- self.assertNotIn("-id3v2_version", _tag_args(self.META, "flac"))
-
- def test_empty_fields_omitted(self):
- meta = TrackMeta(title="Only Title")
- args = _tag_args(meta, "flac")
- self.assertNotIn("artist", args)
- self.assertNotIn("album", args)
- self.assertNotIn("track", args)
-
- def test_track_requires_total(self):
- meta = TrackMeta(title="T", track=3)
- self.assertNotIn("track", _tag_args(meta, "mp3"))
-
-
-class CoverArgsTests(unittest.TestCase):
- def test_mp3_copies_png_stream(self):
- args = _cover_args("mp3", 1)
- self.assertIn("copy", args)
- self.assertIn("attached_pic", args)
- self.assertIn("1:v", args)
-
- def test_m4b_reencodes_to_jpeg(self):
- args = _cover_args("m4b", 2)
- self.assertIn("mjpeg", args)
- self.assertIn("attached_pic", args)
- self.assertIn("3", args) # jpeg quality
-
- def test_ogg_and_wav_have_no_cover(self):
- self.assertEqual(_cover_args("ogg", 1), [])
- self.assertEqual(_cover_args("wav", 1), [])
-
-
-class BuildConcatCommandMetaTests(unittest.TestCase):
- META = TrackMeta(title="Chapter 1", artist="Author", album="Book",
- track=1, total_tracks=3)
-
- def test_cover_added_as_second_input(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
- meta=self.META, cover=Path("cover.png"))
- # The cover is the second input, after the concat list
- self.assertIn("cover.png", cmd)
- self.assertLess(cmd.index("list.txt"), cmd.index("cover.png"))
- self.assertIn("-map", cmd)
- self.assertIn("1:v", cmd)
- self.assertIn("attached_pic", cmd)
- self.assertEqual(cmd[-1], "out.mp3")
-
- def test_audio_explicitly_mapped_when_cover_present(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.flac"), "flac",
- cover=Path("cover.png"))
- self.assertIn("0:a", cmd)
-
- def test_no_cover_keeps_single_input(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
- meta=self.META)
- self.assertEqual(cmd.count("-i"), 1)
- self.assertNotIn("attached_pic", cmd)
-
- def test_ogg_never_gets_cover_input(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.ogg"), "ogg",
- meta=self.META, cover=Path("cover.png"))
- self.assertEqual(cmd.count("-i"), 1)
- self.assertNotIn("attached_pic", cmd)
-
- def test_speed_copy_gets_tags_and_cover(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
- speed=1.5, speed_path=Path("out_1.5.mp3"),
- meta=self.META, cover=Path("cover.png"))
- self.assertEqual(cmd.count("attached_pic"), 2)
- self.assertEqual(cmd.count("title=Chapter 1"), 2)
- self.assertEqual(cmd.count("1:v"), 2)
-
- def test_tags_without_cover_present(self):
- cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
- meta=self.META)
- self.assertIn("title=Chapter 1", cmd)
- self.assertIn("artist=Author", cmd)
- self.assertIn("album=Book", cmd)
- self.assertIn("track=1/3", cmd)
-
-
-class BuildM4bChaptersCommandMetaTests(unittest.TestCase):
- def setUp(self):
- self._original = audio._brand_supported
- audio._brand_supported = True
-
- def tearDown(self):
- audio._brand_supported = self._original
-
- def test_cover_indexed_after_metadata_inputs(self):
- cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"),
- Path("out.m4b"), cover=Path("cover.png"))
- # Inputs: 0=audio, 1=ffmetadata, 2=cover
- self.assertIn("-i", cmd)
- self.assertIn("2:v", cmd)
- self.assertIn("attached_pic", cmd)
-
- def test_speed_variant_cover_is_input_three(self):
- cmd = build_m4b_chapters_command(
- Path("list.txt"), Path("meta.txt"), Path("out.m4b"),
- speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"),
- meta=TrackMeta(title="Book"), cover=Path("cover.png"),
- )
- self.assertEqual(cmd.count("3:v"), 2) # both outputs attach the cover
- self.assertNotIn("2:v", cmd)
- self.assertEqual(cmd.count("title=Book"), 2)
- # Chapter metadata inputs keep their 1/2 mapping
- chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"]
- self.assertEqual(cmd[chapter_flags[0] + 1], "1")
- self.assertEqual(cmd[chapter_flags[1] + 1], "2")
-
- def test_without_cover_regression(self):
- cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"),
- Path("out.m4b"))
- self.assertNotIn("attached_pic", cmd)
- self.assertNotIn("-metadata", cmd)
-
-
-class ConcatAudioFilesTests(unittest.TestCase):
- """Concatenation of sub-request audio into one chunk file."""
-
- @staticmethod
- def _write_wav(path: Path, frames: bytes, framerate: int = 24000) -> Path:
- with wave.open(str(path), "wb") as wav_file:
- wav_file.setnchannels(1)
- wav_file.setsampwidth(2)
- wav_file.setframerate(framerate)
- wav_file.writeframes(frames)
- return path
-
- def test_wav_files_are_merged_in_order(self):
- with tempfile.TemporaryDirectory() as tmp:
- first = self._write_wav(Path(tmp) / "a.wav", b"\x01\x00" * 10)
- second = self._write_wav(Path(tmp) / "b.wav", b"\x02\x00" * 20)
- destination = Path(tmp) / "out.wav"
- concat_audio_files([first, second], destination)
- with wave.open(str(destination), "rb") as wav_file:
- self.assertEqual(wav_file.getframerate(), 24000)
- self.assertEqual(wav_file.getnchannels(), 1)
- self.assertEqual(wav_file.getsampwidth(), 2)
- frames = wav_file.readframes(wav_file.getnframes())
- self.assertEqual(frames, b"\x01\x00" * 10 + b"\x02\x00" * 20)
-
- def test_single_wav_file_is_copied(self):
- with tempfile.TemporaryDirectory() as tmp:
- source = self._write_wav(Path(tmp) / "a.wav", b"\x03\x00" * 15)
- destination = Path(tmp) / "out.wav"
- concat_audio_files([source], destination)
- with wave.open(str(destination), "rb") as wav_file:
- self.assertEqual(wav_file.readframes(wav_file.getnframes()),
- b"\x03\x00" * 15)
-
- def test_empty_source_list_raises(self):
- with tempfile.TemporaryDirectory() as tmp:
- with self.assertRaises(ValueError):
- concat_audio_files([], Path(tmp) / "out.wav")
-
- def test_mismatched_wav_parameters_fall_back_to_ffmpeg(self):
- with tempfile.TemporaryDirectory() as tmp:
- first = self._write_wav(Path(tmp) / "a.wav", b"\x01\x00" * 10, framerate=24000)
- second = self._write_wav(Path(tmp) / "b.wav", b"\x02\x00" * 10, framerate=16000)
- destination = Path(tmp) / "out.wav"
- with patch("converter.audio.shutil.which", return_value=None), \
- self.assertRaises(RuntimeError) as ctx:
- concat_audio_files([first, second], destination)
- self.assertIn("ffmpeg", str(ctx.exception))
- # The wave-module path must not have written a partial output.
- self.assertFalse(destination.exists())
-
- def test_non_wav_input_falls_back_to_ffmpeg(self):
- with tempfile.TemporaryDirectory() as tmp:
- source = Path(tmp) / "part.mp3"
- source.write_bytes(b"not a wav file")
- destination = Path(tmp) / "out.wav"
- with patch("converter.audio.shutil.which", return_value=None), \
- self.assertRaises(RuntimeError) as ctx:
- concat_audio_files([source], destination)
- self.assertIn("ffmpeg", str(ctx.exception))
-
-
-class CombineChunksPrintTests(unittest.TestCase):
- """Single-request runs (audiocpp whole-chapter) omit the chunks suffix."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(audio, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
- self.addCleanup(self._chunks.stop)
-
- def _combine(self, total_chunks, chunk_results, intermediate=False):
- buf = io.StringIO()
- with patch.object(audio.shutil, "which", return_value="/usr/bin/ffmpeg"), \
- patch.object(audio, "atempo_filters", return_value=False), \
- patch.object(audio, "build_concat_command",
- return_value=["ffmpeg"]), \
- patch.object(audio.subprocess, "run",
- return_value=MagicMock(returncode=0)), \
- patch.object(audio, "probe_duration_ms", return_value=1000), \
- patch.object(audio, "verify_output_duration",
- return_value=True), \
- redirect_stdout(buf):
- ok = audio.combine_chunks(
- total_chunks, Path("out.m4b"), chunk_results,
- output_format="m4b", intermediate=intermediate)
- self.assertTrue(ok)
- return buf.getvalue()
-
- def test_single_chunk_omits_chunks_suffix(self):
- chunk = Path(self._tmp.name) / "chunk_0001.wav"
- chunk.write_bytes(b"x")
- out = self._combine(1, {1: chunk})
- self.assertEqual(out.strip(), "[INFO] Saved audiobook: out.m4b")
-
- def test_multi_chunk_keeps_chunks_suffix(self):
- chunk = Path(self._tmp.name) / "chunk_0001.wav"
- chunk.write_bytes(b"x")
- out = self._combine(1, {1: chunk}, intermediate=True)
- self.assertEqual(out.strip(),
- "[INFO] Saved chapter audio (intermediate): out.m4b")
-
- def test_partial_chunk_run_keeps_chunks_suffix(self):
- chunk = Path(self._tmp.name) / "chunk_0001.wav"
- chunk.write_bytes(b"x")
- out = self._combine(2, {1: chunk, 2: chunk})
- self.assertEqual(out.strip(),
- "[INFO] Saved audiobook: out.m4b (2/2 chunks)")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_backends.py b/tests/test_backends.py
deleted file mode 100644
index c0e8d4a..0000000
--- a/tests/test_backends.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""Tests for the backends package registry and detection aggregation."""
-
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from backends import REGISTRY, detect_all, get
-
-
-class RegistryTests(unittest.TestCase):
- def setUp(self):
- # The registry is built lazily on first access (the backend modules
- # pull in converter.tts and its deps, which are only available inside
- # the managed venv). Trigger the build so these tests don't depend on
- # another test class having called detect_all() first.
- get("audiocpp")
-
- def test_registry_has_the_three_backends(self):
- keys = [info.key for info in REGISTRY]
- self.assertEqual(keys, ["audiocpp", "qwen", "faster"])
-
- def test_every_entry_has_detect_and_setup_tui(self):
- for info in REGISTRY:
- self.assertTrue(callable(info.detect), info.key)
- self.assertTrue(callable(info.setup_tui), info.key)
- self.assertIsInstance(info.configure_actions, list)
- for action in info.configure_actions:
- self.assertTrue(callable(action.run))
-
- def test_get_returns_entry_by_key(self):
- self.assertIs(get("audiocpp").key, "audiocpp")
- self.assertIsNone(get("nonexistent"))
-
-
-class DetectAllTests(unittest.TestCase):
- def test_detect_all_returns_one_status_per_backend(self):
- with patch("backends.common.server_running", return_value=False):
- statuses = detect_all()
- self.assertEqual([s.key for s in statuses],
- ["audiocpp", "qwen", "faster"])
- for s in statuses:
- self.assertIn(s.key, ("audiocpp", "qwen", "faster"))
- # ready requires both installed and configured; on a clean
- # machine none are ready.
- if s.ready:
- self.assertTrue(s.installed and s.configured)
- # running is always probed; patched False here so a dev machine
- # running a real server can't flake the test.
- self.assertFalse(s.running)
-
- def test_audiocpp_status_when_cloned_built_configured(self):
- with tempfile.TemporaryDirectory() as td:
- root = Path(td)
- checkout = root / "audio.cpp"
- checkout.mkdir()
- (checkout / "model_specs").mkdir()
- (checkout / "build" / "linux-cuda-release" / "bin").mkdir(
- parents=True)
- (checkout / "build" / "linux-cuda-release" / "bin"
- / "audiocpp_server").write_bytes(b"x")
- (checkout / "server.json").write_text('{"models":[]}',
- encoding="utf-8")
- from backends import audiocpp
- with patch.object(audiocpp, "find_local_checkout",
- return_value=checkout), \
- patch("backends.common.server_running",
- return_value=False):
- status = audiocpp.detect()
- self.assertTrue(status.installed)
- self.assertTrue(status.configured)
- self.assertTrue(status.ready)
- self.assertFalse(status.running)
- self.assertIn("audiocpp_server", status.launch_hint)
-
- def test_audiocpp_running_when_server_probe_succeeds(self):
- from backends import audiocpp
- with patch.object(audiocpp, "find_local_checkout",
- return_value=None), \
- patch("backends.common.server_running", return_value=True):
- status = audiocpp.detect()
- # Not installed (no checkout) but an external server is up.
- self.assertFalse(status.installed)
- self.assertTrue(status.running)
-
- def test_qwen_status_reflects_install(self):
- from backends import qwen
- with patch.object(qwen, "_is_installed", return_value=True), \
- patch("backends.common.server_running", return_value=False):
- status = qwen.detect()
- self.assertTrue(status.installed)
- self.assertTrue(status.configured)
- self.assertFalse(status.running)
- self.assertIn("qwen-tts-demo", status.launch_hint)
- with patch.object(qwen, "_is_installed", return_value=False), \
- patch("backends.common.server_running", return_value=False):
- status = qwen.detect()
- self.assertFalse(status.installed)
- self.assertFalse(status.configured)
-
- def test_qwen_running_when_either_port_is_up(self):
- # Either the CustomVoice port or the Base port counts as running.
- from backends import qwen
- with patch.object(qwen, "_is_installed", return_value=False), \
- patch("backends.common.server_running",
- side_effect=[True, False]):
- status = qwen.detect()
- self.assertTrue(status.running)
- with patch.object(qwen, "_is_installed", return_value=False), \
- patch("backends.common.server_running",
- side_effect=[False, True]):
- status = qwen.detect()
- self.assertTrue(status.running)
-
- def test_faster_status_reflects_install_clone_voices(self):
- from backends import faster
- with tempfile.TemporaryDirectory() as td:
- checkout = Path(td) / "faster-qwen3-tts"
- (checkout / "examples").mkdir(parents=True)
- (checkout / "examples" / "openai_server.py").write_text("x")
- (checkout / "voices.json").write_text('{"default":{}}',
- encoding="utf-8")
- with patch.object(faster, "_is_installed", return_value=True), \
- patch.object(faster, "_checkout",
- return_value=checkout), \
- patch("backends.common.server_running",
- return_value=False):
- status = faster.detect()
- self.assertTrue(status.installed)
- self.assertTrue(status.configured)
- self.assertFalse(status.running)
- self.assertIn("openai_server.py", status.launch_hint)
-
- def test_faster_running_when_server_probe_succeeds(self):
- from backends import faster
- with patch.object(faster, "_is_installed", return_value=False), \
- patch.object(faster, "_is_cloned", return_value=False), \
- patch("backends.common.server_running", return_value=True):
- status = faster.detect()
- self.assertTrue(status.running)
-
-
-class ServerRunningTests(unittest.TestCase):
- """backends.common.server_running: TCP probe against a real socket."""
-
- def test_true_for_open_port(self):
- import socket
-
- from backends import common
- server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- server.bind(("127.0.0.1", 0))
- server.listen(1)
- host, port = server.getsockname()
- url = f"http://127.0.0.1:{port}"
- try:
- self.assertTrue(common.server_running(url))
- finally:
- server.close()
-
- def test_false_for_closed_port(self):
- # Pick an unused port by opening + closing a socket, then probe it.
- import socket
-
- from backends import common
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- s.bind(("127.0.0.1", 0))
- _, port = s.getsockname()
- s.close()
- self.assertFalse(common.server_running(f"http://127.0.0.1:{port}"))
-
- def test_false_for_invalid_url(self):
- from backends import common
- self.assertFalse(common.server_running("not a url"))
- self.assertFalse(common.server_running(""))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_backends_audiocpp.py b/tests/test_backends_audiocpp.py
deleted file mode 100644
index 9882ce1..0000000
--- a/tests/test_backends_audiocpp.py
+++ /dev/null
@@ -1,1062 +0,0 @@
-"""Tests for the audio.cpp backend setup module (backends/audiocpp.py)."""
-
-import io
-import json
-import sys
-import tempfile
-import unittest
-from contextlib import redirect_stdout
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from converter import config
-from backends import audiocpp as make_server
-
-FAKE_CONFIG = (
- 'LANGUAGE = "English"\n'
- "\n"
- 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n'
- "\n"
- "CHUNK_SIZE = 250\n"
-)
-
-FAKE_CONFIG_WITH_MODEL_IDS = (
- 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n'
- "\n"
- 'AUDIOCPP_MODEL_ID = "qwen" # server entry for speaker mode\n'
- 'AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"\n'
-)
-
-
-def _write_spec(checkout: Path, family: str, *, display_name=None,
- tasks=("tts", "clone"), languages=("en",), packages=None,
- category="tts"):
- """Write a minimal model_specs/<family>.json into a fake checkout."""
- specs = checkout / "model_specs"
- specs.mkdir(parents=True, exist_ok=True)
- if packages is None:
- packages = [{
- "id": f"{family}_q8_0", "default": True, "format": "gguf",
- "target_directory": f"{family}-GGUF",
- }]
- spec = {
- "family": family,
- "display_name": display_name or family,
- "category": category,
- "tasks": list(tasks),
- "languages": list(languages),
- "packages": packages,
- }
- (specs / f"{family}.json").write_text(json.dumps(spec), encoding="utf-8")
- return spec
-
-
-def _make_checkout(tmp: Path) -> Path:
- """Create a fake audio.cpp checkout with a realistic model_specs set."""
- checkout = tmp / "audio.cpp"
- checkout.mkdir()
- _write_spec(checkout, "qwen3_tts", display_name="Qwen3-TTS",
- tasks=("tts", "clone", "design"),
- languages=("zh", "en", "ja"),
- packages=[
- {"id": "qwen3_tts_1_7b_base_q8_0", "default": True,
- "format": "gguf",
- "target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF"},
- {"id": "qwen3_tts_1_7b_customvoice_q8_0",
- "format": "gguf",
- "target_directory": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"},
- {"id": "qwen3_tts_1_7b_voicedesign_q8_0",
- "format": "gguf",
- "target_directory": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"},
- ])
- _write_spec(checkout, "higgs_audio_tts", display_name="Higgs Audio v3 TTS 4B",
- languages=("auto",),
- packages=[{
- "id": "higgs_audio_tts_4b_q8_0", "default": True,
- "format": "gguf",
- "target_directory": "Higgs-Audio-v3-TTS-4B-GGUF",
- }])
- _write_spec(checkout, "voxcpm2", display_name="VoxCPM2-2B",
- languages=("en", "zh"),
- packages=[{
- "id": "voxcpm2_q8_0", "default": True, "format": "gguf",
- "target_directory": "VoxCPM2-GGUF",
- }])
- _write_spec(checkout, "index_tts2", display_name="IndexTTS-2",
- languages=("zh", "en"),
- packages=[{
- "id": "index_tts2_q8_0", "default": True, "format": "gguf",
- "target_directory": "IndexTTS2-GGUF",
- }])
- _write_spec(checkout, "pocket_tts", display_name="PocketTTS-100M",
- tasks=("tts", "clone"), languages=("en", "de"),
- packages=[{
- "id": "pocket_tts_q8_0", "default": True, "format": "gguf",
- "target_directory": "PocketTTS-GGUF",
- }])
- _write_spec(checkout, "supertonic", display_name="Supertonic 3",
- tasks=("tts",), languages=("en", "ko"),
- packages=[{
- "id": "supertonic_q8_0", "default": True, "format": "gguf",
- "target_directory": "Supertonic-GGUF",
- }])
- # An ASR family that must be filtered out.
- _write_spec(checkout, "qwen3_asr", display_name="Qwen3-ASR",
- tasks=("asr",), category="asr")
- # A TTS family with no installable packages (must be skipped).
- _write_spec(checkout, "empty_tts", display_name="Empty TTS",
- tasks=("tts",), packages=[])
- return checkout
-
-
-class FindWavFilesTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def _touch(self, name):
- path = self.folder / name
- path.write_bytes(b"x")
- return path
-
- def test_finds_only_wavs_case_insensitive(self):
- self._touch("b.wav")
- self._touch("a.WAV")
- self._touch("notes.txt")
- (self.folder / "sub").mkdir()
- (self.folder / "sub" / "c.wav").write_bytes(b"x")
- names = [path.name for path in make_server.find_wav_files(self.folder)]
- self.assertEqual(names, ["a.WAV", "b.wav"])
-
- def test_sorted_alphabetically_case_insensitive(self):
- for name in ("Zed.wav", "alpha.wav", "Beta.wav"):
- self._touch(name)
- names = [path.name for path in make_server.find_wav_files(self.folder)]
- self.assertEqual(names, ["alpha.wav", "Beta.wav", "Zed.wav"])
-
- def test_empty_directory_returns_empty_list(self):
- self.assertEqual(make_server.find_wav_files(self.folder), [])
-
-
-class DetectWavDirTests(unittest.TestCase):
- """Shallow .wav-directory discovery across the two checkout roots."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.root = Path(self._td.name)
- self.audiocpp = self.root / "audio.cpp"
- self.tts_root = self.root / "tts-audiobook-generator"
- self.audiocpp.mkdir()
- self.tts_root.mkdir()
-
- def tearDown(self):
- self._td.cleanup()
-
- def _wav_dir(self, where, name="voices"):
- directory = where / name
- directory.mkdir(parents=True, exist_ok=True)
- (directory / "voice.wav").write_bytes(b"x")
- return directory
-
- def test_unique_wav_dir_in_tts_root_returned(self):
- found = self._wav_dir(self.tts_root, "voices")
- self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root),
- found)
-
- def test_unique_wav_dir_in_audiocpp_root_returned(self):
- found = self._wav_dir(self.audiocpp, "reference")
- self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root),
- found)
-
- def test_root_itself_containing_wavs_returned(self):
- (self.tts_root / "direct.wav").write_bytes(b"x")
- self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root),
- self.tts_root)
-
- def test_multiple_wav_dirs_returns_none(self):
- self._wav_dir(self.tts_root, "one")
- self._wav_dir(self.audiocpp, "two")
- self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root))
-
- def test_output_dir_of_tts_root_excluded(self):
- self._wav_dir(self.tts_root, "output")
- self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root))
-
- def test_no_wavs_returns_none(self):
- self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root))
-
- def test_nested_wav_dir_not_seen(self):
- nested = self.tts_root / "outer" / "inner"
- nested.mkdir(parents=True)
- (nested / "voice.wav").write_bytes(b"x")
- self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
- self.tts_root))
-
-
-class ConfigPortTests(unittest.TestCase):
- def test_port_parsed_from_config_url(self):
- with patch.object(config, "AUDIOCPP_API_URL",
- "http://127.0.0.1:8080"):
- self.assertEqual(make_server.config_port(), 8080)
-
- def test_missing_port_falls_back(self):
- with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1"):
- self.assertEqual(make_server.config_port(),
- make_server.FALLBACK_PORT)
-
- def test_invalid_url_falls_back(self):
- with patch.object(config, "AUDIOCPP_API_URL", "not a url"):
- self.assertEqual(make_server.config_port(),
- make_server.FALLBACK_PORT)
-
- def test_url_with_port_replaces_port(self):
- self.assertEqual(
- make_server._url_with_port("http://127.0.0.1:8080", 9000),
- "http://127.0.0.1:9000")
-
- def test_url_without_port_adds_port(self):
- self.assertEqual(
- make_server._url_with_port("http://localhost", 8080),
- "http://localhost:8080")
-
-
-class UpdateConfigPortTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.config_path = Path(self._tmp.name) / "config.py"
- self.config_path.write_text(FAKE_CONFIG, encoding="utf-8")
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_rewrites_port_preserving_comment(self):
- changed = make_server.update_config_api_url_port(
- 8080, config_path=self.config_path)
- self.assertTrue(changed)
- text = self.config_path.read_text(encoding="utf-8")
- self.assertIn(
- 'AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server',
- text)
- self.assertIn('LANGUAGE = "English"', text)
- self.assertIn("CHUNK_SIZE = 250", text)
-
- def test_returns_false_when_no_url_line(self):
- path = Path(self._tmp.name) / "other.py"
- path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8")
- self.assertFalse(make_server.update_config_api_url_port(
- 8080, config_path=path))
-
- def test_returns_false_when_port_unchanged(self):
- self.assertFalse(make_server.update_config_api_url_port(
- 9999, config_path=self.config_path))
- self.assertEqual(self.config_path.read_text(encoding="utf-8"),
- FAKE_CONFIG)
-
- def test_returns_false_when_file_missing(self):
- self.assertFalse(make_server.update_config_api_url_port(
- 8080, config_path=Path(self._tmp.name) / "nope.py"))
-
-
-class UpdateConfigModelIdsTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.config_path = Path(self._tmp.name) / "config.py"
- self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
- encoding="utf-8")
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_rewrites_both_ids_preserving_lines(self):
- changed = make_server.update_config_model_ids(
- "higgs", "higgs", config_path=self.config_path)
- self.assertTrue(changed)
- text = self.config_path.read_text(encoding="utf-8")
- self.assertIn('AUDIOCPP_MODEL_ID = "higgs" # server entry for speaker mode',
- text)
- self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
- self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:9999"', text)
-
- def test_clone_id_optional(self):
- changed = make_server.update_config_model_ids(
- "voxcpm2", config_path=self.config_path)
- self.assertTrue(changed)
- text = self.config_path.read_text(encoding="utf-8")
- self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text)
- self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text)
-
- def test_returns_false_when_ids_unchanged(self):
- changed = make_server.update_config_model_ids(
- "qwen", "qwen-clone", config_path=self.config_path)
- self.assertFalse(changed)
- self.assertEqual(self.config_path.read_text(encoding="utf-8"),
- FAKE_CONFIG_WITH_MODEL_IDS)
-
- def test_returns_false_when_lines_missing(self):
- path = Path(self._tmp.name) / "other.py"
- path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8")
- self.assertFalse(make_server.update_config_model_ids(
- "higgs", "higgs", config_path=path))
-
- def test_returns_false_when_file_missing(self):
- self.assertFalse(make_server.update_config_model_ids(
- "higgs", "higgs",
- config_path=Path(self._tmp.name) / "nope.py"))
-
-
-class ResolveWavDirArgTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_resolves_to_absolute(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(str(self.folder)),
- self.folder.resolve())
-
- def test_strips_surrounding_quotes(self):
- quoted = f'"{self.folder}"'
- self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
- self.folder.resolve())
-
- def test_strips_single_quotes(self):
- quoted = f"'{self.folder}'"
- self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
- self.folder.resolve())
-
- def test_strips_whitespace(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(f" {self.folder} "),
- self.folder.resolve())
-
- def test_expands_tilde(self):
- with patch.object(make_server.os.path, "expanduser",
- return_value=str(self.folder)) as mock_expand:
- result = make_server.resolve_wav_dir_arg("~/voices")
- mock_expand.assert_called_once_with("~/voices")
- self.assertEqual(result, self.folder.resolve())
-
- def test_trailing_slash_preserved_as_dir(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(f"{self.folder}/"),
- self.folder.resolve())
-
-
-class NormalizeDirArgTests(unittest.TestCase):
- """Path normalization for the audio.cpp checkout argument."""
-
- def test_expands_tilde_and_resolves(self):
- with patch.object(make_server.os.path, "expanduser",
- return_value="/home/u/audio.cpp") as mock_expand:
- result = make_server.normalize_dir_arg("~/audio.cpp")
- mock_expand.assert_called_once_with("~/audio.cpp")
- self.assertEqual(result, Path("/home/u/audio.cpp").resolve())
-
- def test_strips_quotes_and_whitespace(self):
- with patch.object(make_server.os.path, "expanduser",
- side_effect=lambda s: s):
- result = make_server.normalize_dir_arg(' "/tmp/foo" ')
- self.assertEqual(result, Path("/tmp/foo").resolve())
-
-
-class CheckoutAutoSelectTests(unittest.TestCase):
- """TUI browser auto-accept callback for an audio.cpp checkout."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.root = Path(self._td.name)
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_accepts_audio_cpp_containing_model_specs(self):
- checkout = self.root / "audio.cpp"
- checkout.mkdir()
- (checkout / "model_specs").mkdir()
- self.assertEqual(make_server._checkout_auto_select(checkout),
- checkout)
-
- def test_rejects_audio_cpp_without_model_specs(self):
- checkout = self.root / "audio.cpp"
- checkout.mkdir()
- self.assertIsNone(make_server._checkout_auto_select(checkout))
-
- def test_rejects_other_name_even_with_model_specs(self):
- other = self.root / "not-audiocpp"
- other.mkdir()
- (other / "model_specs").mkdir()
- self.assertIsNone(make_server._checkout_auto_select(other))
-
- def test_rejects_plain_directory(self):
- plain = self.root / "somewhere"
- plain.mkdir()
- self.assertIsNone(make_server._checkout_auto_select(plain))
-
-
-class DefaultModelIdTests(unittest.TestCase):
- def test_preferred_ids_for_tested_families(self):
- self.assertEqual(make_server.default_model_id("qwen3_tts"), "qwen")
- self.assertEqual(make_server.default_model_id("higgs_audio_tts"), "higgs")
- self.assertEqual(make_server.default_model_id("voxcpm2"), "voxcpm2")
- self.assertEqual(make_server.default_model_id("index_tts2"), "indextts2")
-
- def test_derived_id_strips_trailing_tts_and_underscores(self):
- self.assertEqual(make_server.default_model_id("pocket_tts"), "pocket")
- self.assertEqual(make_server.default_model_id("dots_tts"), "dots")
- self.assertEqual(make_server.default_model_id("moss_tts_local"),
- "mossttslocal")
-
-
-class LoadModelCatalogTests(unittest.TestCase):
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.checkout = _make_checkout(Path(self._td.name))
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_includes_tts_families_excludes_asr(self):
- catalog = make_server.load_model_catalog(self.checkout)
- families = [entry["family"] for entry in catalog]
- self.assertIn("qwen3_tts", families)
- self.assertIn("higgs_audio_tts", families)
- self.assertIn("pocket_tts", families)
- self.assertIn("supertonic", families)
- self.assertNotIn("qwen3_asr", families)
-
- def test_skips_families_with_no_packages(self):
- catalog = make_server.load_model_catalog(self.checkout)
- self.assertNotIn("empty_tts",
- [entry["family"] for entry in catalog])
-
- def test_families_sorted_alphabetically_by_display_name(self):
- catalog = make_server.load_model_catalog(self.checkout)
- names = [entry["display_name"].lower() for entry in catalog]
- self.assertEqual(names, sorted(names))
- self.assertNotIn("tested", catalog[0])
- self.assertNotIn("TESTED_FAMILIES", dir(make_server))
-
- def test_default_package_and_target_directory_resolved(self):
- catalog = make_server.load_model_catalog(self.checkout)
- by_family = {entry["family"]: entry for entry in catalog}
- higgs = by_family["higgs_audio_tts"]
- self.assertEqual(higgs["install_id"], "higgs_audio_tts_4b_q8_0")
- self.assertEqual(higgs["default_path"],
- "models/Higgs-Audio-v3-TTS-4B-GGUF")
-
- def test_picks_first_gguf_when_no_default_flag(self):
- _write_spec(self.checkout, "voxcpm2", display_name="VoxCPM2-2B",
- packages=[
- {"id": "voxcpm2_bf16", "format": "gguf",
- "target_directory": "VoxCPM2-GGUF"},
- {"id": "voxcpm2_q8_0", "format": "gguf",
- "target_directory": "VoxCPM2-GGUF"},
- ])
- catalog = make_server.load_model_catalog(self.checkout)
- by_family = {entry["family"]: entry for entry in catalog}
- self.assertEqual(by_family["voxcpm2"]["install_id"], "voxcpm2_bf16")
-
- def test_clone_capability_from_tasks(self):
- catalog = make_server.load_model_catalog(self.checkout)
- by_family = {entry["family"]: entry for entry in catalog}
- self.assertTrue(by_family["higgs_audio_tts"]["clone_capable"])
- self.assertFalse(by_family["supertonic"]["clone_capable"])
-
- def test_missing_model_specs_dir_raises(self):
- empty = Path(self._td.name) / "empty"
- empty.mkdir()
- with self.assertRaises(NotADirectoryError):
- make_server.load_model_catalog(empty)
-
-
-class DetectBackendTests(unittest.TestCase):
- """Backend detection from audio.cpp build directory names."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.checkout = Path(self._td.name) / "audio.cpp"
- self.checkout.mkdir()
-
- def tearDown(self):
- self._td.cleanup()
-
- def _build(self, name, binary="audiocpp_server"):
- build_dir = self.checkout / "build" / name
- bin_dir = build_dir / "bin"
- bin_dir.mkdir(parents=True)
- (bin_dir / binary).write_bytes(b"x")
- return build_dir
-
- def test_no_build_dir_returns_none(self):
- self.assertIsNone(make_server.detect_backend(self.checkout))
-
- def test_unique_linux_backend_detected(self):
- self._build("linux-cuda-release")
- self.assertEqual(make_server.detect_backend(self.checkout), "cuda")
-
- def test_windows_exe_backend_detected(self):
- self._build("windows-vulkan-debug", binary="audiocpp_server.exe")
- self.assertEqual(make_server.detect_backend(self.checkout), "vulkan")
-
- def test_hip_backend_detected(self):
- self._build("linux-hip-release")
- self.assertEqual(make_server.detect_backend(self.checkout), "hip")
-
- def test_cpu_backend_detected(self):
- self._build("linux-cpu-release")
- self.assertEqual(make_server.detect_backend(self.checkout), "cpu")
-
- def test_metal_maps_to_cpu(self):
- self._build("macos-metal-release")
- self.assertEqual(make_server.detect_backend(self.checkout), "cpu")
-
- def test_multiple_backends_returns_none(self):
- self._build("linux-cuda-release")
- self._build("linux-cpu-release")
- self.assertIsNone(make_server.detect_backend(self.checkout))
-
- def test_multiple_builds_same_backend_detected(self):
- self._build("linux-cuda-release")
- self._build("windows-cuda-debug")
- self.assertEqual(make_server.detect_backend(self.checkout), "cuda")
-
- def test_build_dir_without_binary_ignored(self):
- (self.checkout / "build" / "linux-cuda-release").mkdir(parents=True)
- self.assertIsNone(make_server.detect_backend(self.checkout))
-
- def test_non_matching_build_dir_name_ignored(self):
- self._build("linux-mybuild-release")
- self.assertIsNone(make_server.detect_backend(self.checkout))
-
-
-class BackendOptionsTests(unittest.TestCase):
- """Aligned backend menu labels and the [auto-detected] default."""
-
- def test_options_have_aligned_dashes(self):
- options, default_index = make_server._backend_options()
- dash_columns = {label.index(" - ") for label, _ in options}
- self.assertEqual(len(dash_columns), 1)
- self.assertEqual(default_index, 0)
-
- def test_detected_backend_marked_and_defaulted(self):
- options, default_index = make_server._backend_options("vulkan")
- labels = [label for label, _ in options]
- self.assertEqual(default_index, labels.index(next(
- label for label, value in options
- if value == "vulkan" and label.endswith("[auto-detected]"))))
- self.assertTrue(labels[default_index].endswith("[auto-detected]"))
- self.assertEqual(options[default_index][1], "vulkan")
-
- def test_unknown_detected_backend_is_ignored(self):
- options, default_index = make_server._backend_options("opencl")
- self.assertEqual(default_index, 0)
- self.assertFalse(any("[auto-detected]" in label
- for label, _ in options))
-
- def test_labels_keep_backend_values(self):
- options, _ = make_server._backend_options()
- self.assertEqual([value for _, value in options],
- list(make_server.BACKENDS))
-
-
-class BuildServerConfigTests(unittest.TestCase):
- def test_single_entry_without_voice_dir(self):
- entry = make_server.build_model_entry(
- "higgs_audio_tts", "higgs", "models/Higgs-GGUF")
- cfg = make_server.build_server_config(
- "127.0.0.1", 8080, "cuda", False, [entry])
- self.assertEqual(cfg["host"], "127.0.0.1")
- self.assertEqual(cfg["port"], 8080)
- self.assertEqual(cfg["backend"], "cuda")
- self.assertFalse(cfg["lazy_load"])
- self.assertEqual(cfg["models"], [entry])
- self.assertNotIn("voice_dir", cfg)
-
- def test_voice_dir_added_when_given(self):
- entry = make_server.build_model_entry("voxcpm2", "voxcpm2", "models/V")
- cfg = make_server.build_server_config(
- "0.0.0.0", 9000, "cpu", True, [entry],
- voice_dir="/abs/voices")
- self.assertTrue(cfg["lazy_load"])
- self.assertEqual(cfg["voice_dir"], "/abs/voices")
-
- def test_model_entry_shape(self):
- entry = make_server.build_model_entry("index_tts2", "indextts2", "p")
- self.assertEqual(entry["id"], "indextts2")
- self.assertEqual(entry["family"], "index_tts2")
- self.assertEqual(entry["path"], "p")
- self.assertEqual(entry["task"], "tts")
- self.assertEqual(entry["mode"], "offline")
-
- def test_model_entry_design_task(self):
- entry = make_server.build_model_entry(
- "qwen3_tts", "qwen-design", "p", task="vdes")
- self.assertEqual(entry["task"], "vdes")
- self.assertEqual(entry["mode"], "offline")
-
-
-class InstallModelsTests(unittest.TestCase):
- """Printing or auto-running the model install commands."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.checkout = Path(self._td.name) / "audio.cpp"
- self.checkout.mkdir()
- self.manager = self.checkout / "tools" / "model_manager_v2.py"
- self.manager.parent.mkdir()
- self.manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8")
- self.guidance = [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"),
- ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0"),
- ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")]
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_declined_download_prints_commands_deduped(self):
- buf = io.StringIO()
- with redirect_stdout(buf), \
- patch.object(make_server.subprocess, "run") as run:
- make_server._install_models(self.checkout, self.guidance,
- download=False)
- out = buf.getvalue()
- self.assertEqual(out.count("install higgs_audio_tts_4b_q8_0"), 1)
- self.assertEqual(out.count("install qwen3_tts_1_7b_base_q8_0"), 1)
- run.assert_not_called()
-
- def test_accepted_download_runs_each_command(self):
- with patch.object(make_server.subprocess, "run",
- return_value=MagicMock(returncode=0)) as run:
- make_server._install_models(self.checkout, self.guidance,
- download=True)
- self.assertEqual(run.call_count, 2)
- commands = [call[0][0] for call in run.call_args_list]
- self.assertEqual(commands[0],
- [sys.executable, str(self.manager), "install",
- "higgs_audio_tts_4b_q8_0"])
- self.assertEqual(commands[1],
- [sys.executable, str(self.manager), "install",
- "qwen3_tts_1_7b_base_q8_0"])
- for call in run.call_args_list:
- self.assertEqual(call[1]["cwd"], str(self.checkout))
-
- def test_missing_manager_falls_back_to_printing(self):
- self.manager.unlink()
- buf = io.StringIO()
- with redirect_stdout(buf), \
- patch.object(make_server.subprocess, "run") as run:
- make_server._install_models(self.checkout, self.guidance,
- download=True)
- self.assertIn("install higgs_audio_tts_4b_q8_0", buf.getvalue())
- run.assert_not_called()
-
- def test_failed_install_reports_warning_and_continues(self):
- results = iter([MagicMock(returncode=1), MagicMock(returncode=0)])
- buf = io.StringIO()
- with redirect_stdout(buf), \
- patch.object(make_server.subprocess, "run",
- side_effect=lambda *a, **k: next(results)) as run:
- make_server._install_models(self.checkout, self.guidance,
- download=True)
- self.assertEqual(run.call_count, 2)
- self.assertIn("exited with code 1", buf.getvalue())
-
- def test_decide_download_skips_prompt_without_manager(self):
- self.manager.unlink()
- confirm = MagicMock()
- self.assertFalse(make_server._decide_download(self.checkout, confirm))
- confirm.assert_not_called()
-
- def test_decide_download_asks_when_manager_present(self):
- confirm = MagicMock(return_value=True)
- self.assertTrue(make_server._decide_download(self.checkout, confirm))
- confirm.assert_called_once()
-
-
-class TranscribeWavDirTests(unittest.TestCase):
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.folder = Path(self._td.name)
- self.narrator = self.folder / "narrator.wav"
- self.narrator.write_bytes(b"x")
- self.other = self.folder / "other.wav"
- self.other.write_bytes(b"x")
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_transcribes_to_stem_map_with_absolute_paths(self):
- transcripts = {str(self.narrator): "First.",
- str(self.other): "Second."}
- with patch.object(make_server, "transcribe_reference_audio",
- side_effect=lambda path, model_name="base":
- transcripts[path]):
- result = make_server.transcribe_wav_dir(
- [self.narrator, self.other], "base")
- self.assertEqual(list(result), ["narrator", "other"])
- self.assertEqual(result["narrator"], "First.")
-
- def test_failed_transcription_keeps_empty_string(self):
- with patch.object(make_server, "transcribe_reference_audio",
- return_value=None):
- result = make_server.transcribe_wav_dir([self.narrator], "base")
- self.assertEqual(result["narrator"], "")
-
- def test_whisper_model_name_passed_through(self):
- with patch.object(make_server, "transcribe_reference_audio",
- return_value="text") as mock_transcribe:
- make_server.transcribe_wav_dir([self.narrator], "large-v3")
- self.assertEqual(mock_transcribe.call_args.kwargs["model_name"],
- "large-v3")
-
- def test_write_prompt_text_format(self):
- path = make_server.write_prompt_text(
- self.folder, {"narrator": "Hello.", "other": "World."})
- self.assertEqual(path, self.folder / make_server.PROMPT_TEXT_FILENAME)
- text = path.read_text(encoding="utf-8")
- self.assertIn("narrator|Hello.", text)
- self.assertIn("other|World.", text)
-
-
-class DesignPackageTests(unittest.TestCase):
- """Voice-design package detection."""
-
- def test_detects_voicedesign_in_id(self):
- self.assertTrue(make_server.is_design_package(
- {"id": "qwen3_tts_1_7b_voicedesign_q8_0"}))
-
- def test_detects_voicedesign_in_directory(self):
- self.assertTrue(make_server.is_design_package(
- {"target_directory": "Foo-VoiceDesign-GGUF"}))
-
- def test_detects_separated_voice_design(self):
- self.assertTrue(make_server.is_design_package(
- {"display_name": "Voice Design Q8_0"}))
-
- def test_ignores_other_packages(self):
- self.assertFalse(make_server.is_design_package(
- {"id": "higgs_audio_tts_4b_q8_0"}))
- self.assertFalse(make_server.is_design_package({}))
-
-
-class PackageDirOptionsTests(unittest.TestCase):
- """Grouping a family's packages into distinct target directories."""
-
- def test_groups_precisions_and_marks_recommended(self):
- entry = {
- "family": "qwen3_tts",
- "packages": [
- {"id": "base_q8", "default": True, "format": "gguf",
- "target_directory": "Base-GGUF"},
- {"id": "base_bf16", "format": "gguf",
- "target_directory": "Base-GGUF"},
- {"id": "voicedesign_q8", "format": "gguf",
- "target_directory": "VoiceDesign-GGUF"},
- ],
- }
- options = make_server.package_dir_options(entry)
- self.assertEqual([o["target_directory"] for o in options],
- ["Base-GGUF", "VoiceDesign-GGUF"])
- self.assertTrue(options[0]["recommended"])
- self.assertFalse(options[0]["design"])
- self.assertFalse(options[1]["recommended"])
- self.assertTrue(options[1]["design"])
- self.assertEqual(options[0]["install_id"], "base_q8")
-
- def test_recommended_comes_first_even_if_listed_later(self):
- entry = {
- "family": "demo_tts",
- "packages": [
- {"id": "demo_other", "format": "gguf",
- "target_directory": "Other-GGUF"},
- {"id": "demo_default", "default": True, "format": "gguf",
- "target_directory": "Default-GGUF"},
- ],
- }
- options = make_server.package_dir_options(entry)
- self.assertEqual([o["target_directory"] for o in options],
- ["Default-GGUF", "Other-GGUF"])
-
-
-class FindAudiocppServerBinTests(unittest.TestCase):
- """Locating the built audiocpp_server binary."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.checkout = Path(self._td.name) / "audio.cpp"
- self.checkout.mkdir()
-
- def tearDown(self):
- self._td.cleanup()
-
- def _build(self, name, binary="audiocpp_server"):
- bin_dir = self.checkout / "build" / name / "bin"
- bin_dir.mkdir(parents=True)
- (bin_dir / binary).write_bytes(b"x")
-
- def test_no_build_dir_returns_none(self):
- self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout))
-
- def test_finds_built_binary(self):
- self._build("linux-cuda-release")
- self.assertEqual(
- make_server.find_audiocpp_server_bin(self.checkout),
- self.checkout / "build" / "linux-cuda-release" / "bin"
- / "audiocpp_server")
-
- def test_finds_windows_exe(self):
- self._build("windows-vulkan-debug", binary="audiocpp_server.exe")
- self.assertEqual(
- make_server.find_audiocpp_server_bin(self.checkout).name,
- "audiocpp_server.exe")
-
- def test_build_dir_without_binary_returns_none(self):
- (self.checkout / "build" / "linux-cuda-release" / "bin").mkdir(
- parents=True)
- self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout))
-
-
-class BuildAudiocppTests(unittest.TestCase):
- """Running the audio.cpp build helper script."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.checkout = Path(self._td.name) / "audio.cpp"
- self.checkout.mkdir()
- self.scripts = self.checkout / "scripts"
- self.scripts.mkdir()
- (self.scripts / "build_linux.sh").write_text("#!/bin/sh\n",
- encoding="utf-8")
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_runs_build_script_with_backend_and_target(self):
- with patch.object(make_server.common, "run_console_subprocess",
- return_value=0) as run:
- rc = make_server.build_audiocpp(self.checkout, "cuda")
- self.assertEqual(rc, 0)
- argv = run.call_args[0][0]
- self.assertEqual(argv[:3], ["sh", str(self.scripts / "build_linux.sh"),
- "--backend"])
- self.assertIn("cuda", argv)
- self.assertIn("--target", argv)
- self.assertIn("audiocpp_server", argv)
- self.assertEqual(run.call_args[1]["cwd"], self.checkout)
-
- def test_missing_script_returns_nonzero(self):
- for f in self.scripts.iterdir():
- f.unlink()
- rc = make_server.build_audiocpp(self.checkout, "cuda")
- self.assertNotEqual(rc, 0)
-
-
-class AudiocppDetectTests(unittest.TestCase):
- """backends.audiocpp.detect() status reporting."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.root = Path(self._td.name)
- self.checkout = _make_checkout(self.root)
-
- def tearDown(self):
- self._td.cleanup()
-
- def test_not_cloned(self):
- with patch.object(make_server, "find_local_checkout", return_value=None):
- status = make_server.detect()
- self.assertFalse(status.installed)
- self.assertFalse(status.configured)
- self.assertIn("not cloned", status.details[0])
-
- def test_cloned_not_built_not_configured(self):
- with patch.object(make_server, "find_local_checkout",
- return_value=self.checkout), \
- patch.object(make_server, "find_audiocpp_server_bin",
- return_value=None):
- status = make_server.detect()
- self.assertFalse(status.installed)
- self.assertFalse(status.configured)
- self.assertEqual(status.launch_hint, "")
-
- def test_built_and_configured_ready(self):
- binary = self.checkout / "build" / "linux-cuda-release" / "bin" \
- / "audiocpp_server"
- binary.parent.mkdir(parents=True)
- binary.write_bytes(b"x")
- server_json = self.checkout / "server.json"
- server_json.write_text('{"models":[]}', encoding="utf-8")
- with patch.object(make_server, "find_local_checkout",
- return_value=self.checkout):
- status = make_server.detect()
- self.assertTrue(status.installed)
- self.assertTrue(status.configured)
- self.assertIn(str(binary), status.launch_hint)
- self.assertIn(str(server_json), status.launch_hint)
-
-
-class NonInteractiveMainTests(unittest.TestCase):
- """The flag-only (non-TUI) path through main(), end to end."""
-
- def setUp(self):
- self._td = tempfile.TemporaryDirectory()
- self.root = Path(self._td.name)
- self.folder = self.root / "wavs"
- self.folder.mkdir()
- self.output = self.root / "server.json"
- self.checkout = _make_checkout(self.root)
- # Isolate config.py rewrites so no test touches the real one.
- self.fake_config = self.root / "config.py"
- self.fake_config.write_text(FAKE_CONFIG, encoding="utf-8")
- patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config)
- patcher.start()
- self.addCleanup(patcher.stop)
- # Tests run without a tty -> main() takes the non-interactive path.
- patcher = patch.object(make_server, "_interactive", return_value=False)
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def tearDown(self):
- self._td.cleanup()
-
- def _run(self, argv, transcribe=None, whisper="faster_whisper"):
- argv = ["backends/audiocpp.py"] + argv
- transcribe_effect = transcribe if transcribe is not None \
- else MagicMock()
- with patch.object(sys, "argv", argv), \
- patch.object(make_server, "transcribe_reference_audio",
- side_effect=transcribe_effect), \
- patch.object(make_server, "whisper_backend_available",
- return_value=whisper):
- return make_server.main()
-
- def _args(self, *extra):
- return ["--wavs", str(self.folder), "--output", str(self.output),
- "--audiocpp-dir", str(self.checkout)] + list(extra)
-
- def test_default_run_hosts_recommended_entry(self):
- exit_code = self._run(
- self._args("--families", "higgs_audio_tts", "--no-sync-model-ids"))
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(data["host"], "127.0.0.1")
- self.assertEqual(data["port"], make_server.config_port())
- self.assertEqual(data["backend"], "cuda")
- self.assertFalse(data["lazy_load"])
- self.assertEqual([m["id"] for m in data["models"]], ["higgs"])
- self.assertNotIn("voice_dir", data)
-
- def test_port_sync_accepted_updates_config(self):
- with patch.object(config, "AUDIOCPP_API_URL",
- "http://127.0.0.1:9999"):
- exit_code = self._run(
- self._args("--families", "higgs_audio_tts", "--port", "8080",
- "--no-sync-model-ids"))
- self.assertEqual(exit_code, 0)
- self.assertIn('"http://127.0.0.1:8080"',
- self.fake_config.read_text(encoding="utf-8"))
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(data["port"], 8080)
-
- def test_port_sync_declined_keeps_config(self):
- with patch.object(config, "AUDIOCPP_API_URL",
- "http://127.0.0.1:9999"):
- exit_code = self._run(
- self._args("--families", "higgs_audio_tts", "--port", "8080",
- "--no-sync-port", "--no-sync-model-ids"))
- self.assertEqual(exit_code, 0)
- self.assertIn('"http://127.0.0.1:9999"',
- self.fake_config.read_text(encoding="utf-8"))
-
- def test_model_id_sync_accepted_updates_config(self):
- self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
- encoding="utf-8")
- exit_code = self._run(self._args("--families", "higgs_audio_tts"))
- self.assertEqual(exit_code, 0)
- text = self.fake_config.read_text(encoding="utf-8")
- self.assertIn('AUDIOCPP_MODEL_ID = "higgs"', text)
- self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
-
- def test_multi_family_lazy_with_voice_dir(self):
- (self.folder / "narrator.wav").write_bytes(b"x")
- exit_code = self._run(
- self._args("--families", "qwen3_tts,higgs_audio_tts",
- "--no-sync-model-ids"),
- transcribe=lambda path, model_name="base": "a transcript")
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual([m["id"] for m in data["models"]], ["qwen", "higgs"])
- self.assertTrue(data["lazy_load"])
- self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
- prompt = (self.folder / make_server.PROMPT_TEXT_FILENAME).read_text(
- encoding="utf-8")
- self.assertIn("narrator|a transcript", prompt)
-
- def test_force_overwrites_existing_output(self):
- self.output.write_text('{"old": true}', encoding="utf-8")
- exit_code = self._run(
- self._args("--families", "higgs_audio_tts", "--force",
- "--no-sync-model-ids"))
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(len(data["models"]), 1)
-
- def test_existing_output_declined_keeps_file(self):
- self.output.write_text('{"old": true}', encoding="utf-8")
- exit_code = self._run(
- self._args("--families", "higgs_audio_tts", "--no-sync-model-ids"))
- self.assertEqual(exit_code, 1)
- self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")),
- {"old": True})
-
- def test_all_packages_hosts_design_as_vdes(self):
- exit_code = self._run(
- self._args("--families", "qwen3_tts", "--all-packages",
- "--no-sync-model-ids"))
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- by_id = {m["id"]: m for m in data["models"]}
- self.assertIn("qwen-design", by_id)
- self.assertEqual(by_id["qwen-design"]["task"], "vdes")
- # The non-design packages are hosted with task "tts".
- self.assertTrue(any(m["id"] in ("qwen", "qwen-2") and m["task"] == "tts"
- for m in data["models"]))
-
- def test_unknown_family_rejected(self):
- with self.assertRaises(SystemExit) as ctx:
- self._run(self._args("--families", "not_a_family",
- "--no-sync-model-ids"))
- self.assertEqual(ctx.exception.code, 2)
-
- def test_missing_checkout_rejected(self):
- with patch.object(make_server, "find_local_checkout",
- return_value=None), \
- self.assertRaises(SystemExit) as ctx:
- self._run(["--families", "higgs_audio_tts", "--output",
- str(self.output), "--no-sync-model-ids"])
- self.assertEqual(ctx.exception.code, 2)
-
- def test_missing_wav_dir_rejected(self):
- missing = self.root / "nope"
- with self.assertRaises(SystemExit) as ctx:
- self._run(["--wavs", str(missing), "--output", str(self.output),
- "--audiocpp-dir", str(self.checkout),
- "--families", "higgs_audio_tts", "--no-sync-model-ids"])
- self.assertEqual(ctx.exception.code, 2)
-
- def test_families_required_in_noninteractive_run(self):
- with self.assertRaises(SystemExit) as ctx:
- self._run(self._args("--no-sync-model-ids"))
- self.assertEqual(ctx.exception.code, 2)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_backends_envs.py b/tests/test_backends_envs.py
deleted file mode 100644
index cf4ecc6..0000000
--- a/tests/test_backends_envs.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Tests for the managed Python environment (backends/envs.py)."""
-
-import sys
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from backends import envs
-
-
-class EnvPathTests(unittest.TestCase):
- """Platform-aware path helpers (no venv actually created)."""
-
- def test_env_dir_under_envs_tts(self):
- self.assertEqual(envs.ENV_DIR.name, "tts")
- self.assertEqual(envs.ENV_DIR.parent.name, "envs")
-
- def test_env_python_posix(self):
- with patch.object(envs, "_is_windows", return_value=False):
- self.assertEqual(envs.env_python(),
- envs.ENV_DIR / "bin" / "python")
-
- def test_env_python_windows(self):
- with patch.object(envs, "_is_windows", return_value=True):
- self.assertEqual(envs.env_python(),
- envs.ENV_DIR / "Scripts" / "python.exe")
-
- def test_env_script_posix(self):
- with patch.object(envs, "_is_windows", return_value=False):
- self.assertEqual(envs.env_script("qwen-tts-demo"),
- envs.ENV_DIR / "bin" / "qwen-tts-demo")
-
- def test_env_script_windows(self):
- with patch.object(envs, "_is_windows", return_value=True):
- self.assertEqual(envs.env_script("qwen-tts-demo"),
- envs.ENV_DIR / "Scripts" / "qwen-tts-demo.exe")
-
- def test_env_exists_false_when_python_missing(self):
- with patch.object(envs, "env_python",
- return_value=Path("/no/such/path/python")):
- self.assertFalse(envs.env_exists())
-
- def test_is_managed_env_compares_resolved_executable(self):
- fake_env_python = Path("/tmp/opencode/managed-env/bin/python")
- with patch.object(envs, "env_python", return_value=fake_env_python), \
- patch.object(sys, "executable", str(fake_env_python)):
- self.assertTrue(envs.is_managed_env())
- with patch.object(envs, "env_python", return_value=fake_env_python), \
- patch.object(sys, "executable", "/usr/bin/python3"):
- self.assertFalse(envs.is_managed_env())
-
-
-class CreateEnvTests(unittest.TestCase):
- def test_create_env_invokes_venv_module(self):
- with patch.object(envs.common, "run_console_subprocess",
- return_value=0) as run:
- rc = envs.create_env()
- self.assertEqual(rc, 0)
- argv = run.call_args[0][0]
- self.assertEqual(argv[0], sys.executable)
- self.assertEqual(argv[1], "-m")
- self.assertEqual(argv[2], "venv")
- self.assertEqual(argv[3], str(envs.ENV_DIR))
-
- def test_create_env_reports_remediation_on_failure(self):
- with patch.object(envs.common, "run_console_subprocess",
- return_value=1):
- rc = envs.create_env()
- self.assertEqual(rc, 1)
-
-
-class PipInstallTests(unittest.TestCase):
- def test_creates_env_first_when_missing(self):
- calls = []
-
- def fake_run(argv):
- calls.append(list(argv))
- return 0
-
- with patch.object(envs, "env_exists", return_value=False), \
- patch.object(envs, "create_env", return_value=0) as mk, \
- patch.object(envs.common, "run_console_subprocess",
- side_effect=fake_run):
- rc = envs.pip_install(["qwen-tts"])
- self.assertEqual(rc, 0)
- mk.assert_called_once_with()
- # The actual pip call targets the venv's python.
- self.assertEqual(calls[0][0], str(envs.env_python()))
- self.assertIn("pip", calls[0])
- self.assertIn("qwen-tts", calls[0])
-
- def test_skips_create_when_env_exists(self):
- with patch.object(envs, "env_exists", return_value=True), \
- patch.object(envs, "create_env") as mk, \
- patch.object(envs.common, "run_console_subprocess",
- return_value=0):
- envs.pip_install(["qwen-tts"])
- mk.assert_not_called()
-
- def test_returns_nonzero_when_create_fails(self):
- with patch.object(envs, "env_exists", return_value=False), \
- patch.object(envs, "create_env", return_value=1), \
- patch.object(envs.common, "run_console_subprocess") as run:
- rc = envs.pip_install(["qwen-tts"])
- self.assertEqual(rc, 1)
- run.assert_not_called()
-
-
-class ModuleAvailableTests(unittest.TestCase):
- def test_false_when_env_missing(self):
- with patch.object(envs, "env_exists", return_value=False):
- self.assertFalse(envs.module_available("qwen_tts"))
-
- def test_true_when_subprocess_exits_zero(self):
- import subprocess
- fake = subprocess.CompletedProcess(args=["x"], returncode=0)
- with patch.object(envs, "env_exists", return_value=True), \
- patch("subprocess.run", return_value=fake) as run:
- self.assertTrue(envs.module_available("qwen_tts"))
- argv = run.call_args[0][0]
- self.assertEqual(argv[0], str(envs.env_python()))
- self.assertIn("import qwen_tts", argv[2])
-
- def test_false_when_subprocess_exits_nonzero(self):
- import subprocess
- fake = subprocess.CompletedProcess(args=["x"], returncode=1)
- with patch.object(envs, "env_exists", return_value=True), \
- patch("subprocess.run", return_value=fake):
- self.assertFalse(envs.module_available("qwen_tts"))
-
- def test_false_on_timeout(self):
- import subprocess
- with patch.object(envs, "env_exists", return_value=True), \
- patch("subprocess.run",
- side_effect=subprocess.TimeoutExpired(cmd="x", timeout=1)):
- self.assertFalse(envs.module_available("qwen_tts"))
-
-
-class EnsureAppEnvTests(unittest.TestCase):
- def test_creates_env_then_installs_when_marker_invalid(self):
- with patch.object(envs, "env_exists", return_value=False), \
- patch.object(envs, "create_env", return_value=0), \
- patch.object(envs, "_marker_valid", return_value=False), \
- patch.object(envs, "install_requirements", return_value=0), \
- patch.object(envs, "_write_marker") as mk:
- envs.ensure_app_env()
- mk.assert_called_once_with()
-
- def test_raises_when_create_fails(self):
- with patch.object(envs, "env_exists", return_value=False), \
- patch.object(envs, "create_env", return_value=1):
- with self.assertRaises(RuntimeError):
- envs.ensure_app_env()
-
- def test_raises_when_install_fails(self):
- with patch.object(envs, "env_exists", return_value=True), \
- patch.object(envs, "_marker_valid", return_value=False), \
- patch.object(envs, "install_requirements", return_value=1):
- with self.assertRaises(RuntimeError):
- envs.ensure_app_env()
-
- def test_skips_install_when_marker_valid(self):
- with patch.object(envs, "env_exists", return_value=True), \
- patch.object(envs, "_marker_valid", return_value=True), \
- patch.object(envs, "install_requirements") as mk:
- envs.ensure_app_env()
- mk.assert_not_called()
-
-
-class BootstrapTests(unittest.TestCase):
- def test_noop_when_already_managed(self):
- with patch.object(envs, "is_managed_env", return_value=True), \
- patch.object(envs, "ensure_app_env") as mk, \
- patch("os.execv") as ex:
- envs.bootstrap("/path/to/audiobook.py")
- mk.assert_not_called()
- ex.assert_not_called()
-
- def test_ensures_env_then_execvs(self):
- with patch.object(envs, "is_managed_env", return_value=False), \
- patch.object(envs, "ensure_app_env") as mk_env, \
- patch("os.execv") as ex, \
- patch.object(sys, "argv", ["audiobook.py", "--backend", "qwen"]):
- envs.bootstrap("/path/to/audiobook.py")
- mk_env.assert_called_once_with()
- py = str(envs.env_python())
- args = ex.call_args[0]
- self.assertEqual(args[0], py)
- self.assertEqual(args[1][0], py)
- self.assertTrue(args[1][1].endswith("audiobook.py"))
- self.assertEqual(args[1][2:], ["--backend", "qwen"])
-
- def test_exits_when_ensure_raises(self):
- with patch.object(envs, "is_managed_env", return_value=False), \
- patch.object(envs, "ensure_app_env",
- side_effect=RuntimeError("boom")), \
- patch("os.execv") as ex, \
- self.assertRaises(SystemExit):
- envs.bootstrap("/path/to/audiobook.py")
- ex.assert_not_called()
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_backends_faster.py b/tests/test_backends_faster.py
deleted file mode 100644
index 641f6ee..0000000
--- a/tests/test_backends_faster.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""Tests for the faster-qwen3-tts backend setup module (backends/faster.py)."""
-
-import json
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from backends import faster as make_voices
-
-
-class FindWavFilesTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def _touch(self, name):
- path = self.folder / name
- path.write_bytes(b"x")
- return path
-
- def test_finds_only_wavs_case_insensitive(self):
- self._touch("b.wav")
- self._touch("a.WAV")
- self._touch("notes.txt")
- (self.folder / "sub").mkdir()
- (self.folder / "sub" / "c.wav").write_bytes(b"x")
- names = [path.name for path in make_voices.find_wav_files(self.folder)]
- self.assertEqual(names, ["a.WAV", "b.wav"])
-
- def test_sorted_alphabetically_case_insensitive(self):
- for name in ("Zed.wav", "alpha.wav", "Beta.wav"):
- self._touch(name)
- names = [path.name for path in make_voices.find_wav_files(self.folder)]
- self.assertEqual(names, ["alpha.wav", "Beta.wav", "Zed.wav"])
-
- def test_empty_directory_returns_empty_list(self):
- self.assertEqual(make_voices.find_wav_files(self.folder), [])
-
-
-class BuildVoicesTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- self.narrator = self.folder / "narrator.wav"
- self.narrator.write_bytes(b"x")
- self.other = self.folder / "other.wav"
- self.other.write_bytes(b"x")
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_voices_named_after_basenames_with_absolute_paths(self):
- transcripts = {str(self.narrator): "First transcript.",
- str(self.other): "Second transcript."}
- with patch.object(make_voices, "transcribe_reference_audio",
- side_effect=lambda path, model_name="base": transcripts[path]):
- voices = make_voices.build_voices([self.narrator, self.other],
- "English", "base")
- self.assertEqual(list(voices), ["narrator", "other"])
- self.assertEqual(voices["narrator"]["ref_text"], "First transcript.")
- self.assertEqual(voices["narrator"]["language"], "English")
- self.assertTrue(Path(voices["narrator"]["ref_audio"]).is_absolute())
- self.assertEqual(Path(voices["narrator"]["ref_audio"]), self.narrator.resolve())
-
- def test_failed_transcription_keeps_entry_with_empty_text(self):
- with patch.object(make_voices, "transcribe_reference_audio",
- return_value=None):
- voices = make_voices.build_voices([self.narrator], "English", "base")
- self.assertEqual(voices["narrator"]["ref_text"], "")
-
- def test_whisper_model_name_is_passed_through(self):
- with patch.object(make_voices, "transcribe_reference_audio",
- return_value="text") as mock_transcribe:
- make_voices.build_voices([self.narrator], "English", "large-v3")
- self.assertEqual(mock_transcribe.call_args.kwargs["model_name"], "large-v3")
-
-
-class MainTests(unittest.TestCase):
- """The flag-only (non-TUI) path through main(), end to end."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- (self.folder / "narrator.wav").write_bytes(b"x")
- (self.folder / "alpha.wav").write_bytes(b"x")
- self.output = self.folder / "voices.json"
- # Avoid touching the real converter/config.py and pip/git.
- patcher = patch.object(make_voices.common, "update_config_value",
- return_value=False)
- patcher.start()
- self.addCleanup(patcher.stop)
- patcher = patch.object(make_voices, "_interactive", return_value=False)
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def _run(self, argv):
- with patch.object(sys, "argv", ["backends/faster.py"] + argv), \
- patch.object(make_voices, "transcribe_reference_audio",
- return_value="hello"):
- return make_voices.main()
-
- def test_writes_json_with_alphabetical_voice_order(self):
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--skip-install", "--skip-clone"])
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(list(data), ["alpha", "narrator"])
- self.assertEqual(data["alpha"]["ref_text"], "hello")
- self.assertEqual(data["alpha"]["language"], "English")
-
- def test_custom_output_path(self):
- custom = Path(self._tmp.name) / "custom.json"
- exit_code = self._run([str(self.folder), "--output", str(custom),
- "--skip-install", "--skip-clone"])
- self.assertEqual(exit_code, 0)
- self.assertTrue(custom.exists())
- self.assertFalse(self.output.exists())
-
- def test_invalid_language_errors_before_work(self):
- with patch.object(make_voices, "transcribe_reference_audio") as mock_transcribe:
- with self.assertRaises(SystemExit) as ctx:
- self._run([str(self.folder), "--output", str(self.output),
- "--language", "klingon", "--skip-install",
- "--skip-clone"])
- self.assertEqual(ctx.exception.code, 2)
- mock_transcribe.assert_not_called()
-
- def test_missing_input_dir_errors(self):
- with self.assertRaises(SystemExit) as ctx:
- self._run([str(self.folder / "nope"), "--output", str(self.output),
- "--skip-install", "--skip-clone"])
- self.assertEqual(ctx.exception.code, 2)
-
- def test_no_wav_files_returns_error(self):
- empty = Path(tempfile.mkdtemp())
- try:
- exit_code = self._run([str(empty), "--output",
- str(empty / "voices.json"),
- "--skip-install", "--skip-clone"])
- self.assertEqual(exit_code, 1)
- finally:
- empty.rmdir()
-
- def test_existing_output_declined_keeps_file(self):
- self.output.write_text('{"old": true}', encoding="utf-8")
- with patch.object(make_voices, "transcribe_reference_audio") as mock_transcribe:
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--skip-install", "--skip-clone"])
- self.assertEqual(exit_code, 1)
- mock_transcribe.assert_not_called()
- self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")),
- {"old": True})
-
- def test_force_overwrites_without_prompt(self):
- self.output.write_text('{"old": true}', encoding="utf-8")
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--force", "--skip-install", "--skip-clone"])
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(list(data), ["alpha", "narrator"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_backends_servers.py b/tests/test_backends_servers.py
deleted file mode 100644
index 02b65e6..0000000
--- a/tests/test_backends_servers.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""Tests for the server lifecycle module (backends/servers.py)."""
-
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from backends import ServerSpec, servers
-
-
-class StartTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.dir = Path(self._tmp.name)
- # A fake executable so Path(argv[0]).exists() passes.
- self.exe = self.dir / "fake_server"
- self.exe.write_bytes(b"#!/bin/sh\n")
- self.spec = ServerSpec("test", "http://127.0.0.1:9999",
- [str(self.exe), "--port", "9999"])
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_returns_false_when_executable_missing(self):
- spec = ServerSpec("nope", "http://127.0.0.1:1", ["/no/such/binary"])
- with patch.object(servers, "LOG_DIR", self.dir):
- self.assertFalse(servers.start(spec))
-
- def test_noop_when_already_running(self):
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch("backends.common.server_running", return_value=True), \
- patch("subprocess.Popen") as mk:
- self.assertTrue(servers.start(self.spec))
- mk.assert_not_called()
-
- def test_happy_path_spawns_and_polls_until_ready(self):
- proc = MagicMock()
- proc.pid = 4242
- proc.poll.return_value = None # process still running
- # server_running: False on the pre-check, True once inside the loop.
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch("subprocess.Popen", return_value=proc) as mk, \
- patch("backends.common.server_running",
- side_effect=[False, True]), \
- patch("time.sleep"):
- ok = servers.start(self.spec)
- self.assertTrue(ok)
- mk.assert_called_once()
- # Pid file written.
- self.assertEqual(
- (self.dir / "test-server.pid").read_text(encoding="utf-8"),
- "4242")
-
- def test_returns_false_when_process_exits_early(self):
- proc = MagicMock()
- proc.pid = 99
- proc.poll.return_value = 1 # exited with code 1
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch("subprocess.Popen", return_value=proc), \
- patch("backends.common.server_running", return_value=False), \
- patch("time.sleep"):
- ok = servers.start(self.spec)
- self.assertFalse(ok)
- # Pid file cleaned up after early exit.
- self.assertFalse((self.dir / "test-server.pid").exists())
-
- def test_returns_false_on_timeout(self):
- proc = MagicMock()
- proc.pid = 7
- proc.poll.return_value = None
- # time.time: first call < deadline loop entry, then past deadline.
- times = iter([0.0, float(servers.SERVER_START_TIMEOUT + 1)])
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch("subprocess.Popen", return_value=proc), \
- patch("backends.common.server_running", return_value=False), \
- patch("time.sleep"), \
- patch("time.time", side_effect=lambda: next(times)):
- ok = servers.start(self.spec)
- self.assertFalse(ok)
-
-
-class StopTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.dir = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def _write_pid(self, name, pid):
- (self.dir / f"{name}-server.pid").write_text(str(pid),
- encoding="utf-8")
-
- def test_returns_false_when_no_pid_file(self):
- with patch.object(servers, "LOG_DIR", self.dir):
- self.assertFalse(servers.stop("test"))
-
- def test_stops_alive_process_and_removes_pid_file(self):
- self._write_pid("test", 1234)
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch.object(servers, "_pid_alive", return_value=True), \
- patch.object(servers, "_kill_pid", return_value=True) as mk:
- ok = servers.stop("test")
- self.assertTrue(ok)
- mk.assert_called_once_with(1234)
- self.assertFalse((self.dir / "test-server.pid").exists())
-
- def test_already_dead_returns_true_and_cleans_pid_file(self):
- self._write_pid("test", 1234)
- with patch.object(servers, "LOG_DIR", self.dir), \
- patch.object(servers, "_pid_alive", return_value=False), \
- patch.object(servers, "_kill_pid") as mk:
- ok = servers.stop("test")
- self.assertTrue(ok)
- mk.assert_not_called()
- self.assertFalse((self.dir / "test-server.pid").exists())
-
- def test_corrupt_pid_file_returns_false_and_cleans(self):
- (self.dir / "test-server.pid").write_text("not-a-number",
- encoding="utf-8")
- with patch.object(servers, "LOG_DIR", self.dir):
- self.assertFalse(servers.stop("test"))
- self.assertFalse((self.dir / "test-server.pid").exists())
-
-
-class PidForTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.dir = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_none_when_no_pid_file(self):
- with patch.object(servers, "LOG_DIR", self.dir):
- self.assertIsNone(servers.pid_for("test"))
-
- def test_returns_pid_from_file(self):
- (self.dir / "test-server.pid").write_text("555\n",
- encoding="utf-8")
- with patch.object(servers, "LOG_DIR", self.dir):
- self.assertEqual(servers.pid_for("test"), 555)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_chunking.py b/tests/test_chunking.py
deleted file mode 100644
index 2904e40..0000000
--- a/tests/test_chunking.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""Tests for text chunking."""
-
-import unittest
-from unittest.mock import patch
-
-from converter import config
-from converter.chunking import split_into_chunks
-
-
-class ChunkSizeDefaultTests(unittest.TestCase):
- """Guard the request-size setting: each API call is one model
- generation, and the servers silently truncate audio when a single
- generation runs too long (~2.5 min faster backend, ~11 min Qwen
- demo), so the default chunk size must stay well inside that budget.
- There is no hard ceiling beyond CHUNK_SIZE; users raising it accept
- the truncation risk themselves."""
-
- def test_default_chunk_size_within_single_generation_budget(self):
- self.assertLessEqual(config.CHUNK_SIZE, 300)
-
- def test_default_chunk_size_is_positive(self):
- self.assertGreaterEqual(config.CHUNK_SIZE, 1)
-
-
-class RequestSizeTests(unittest.TestCase):
- def test_oversized_chunk_size_is_honored(self):
- # No clamping: whatever size is configured (or requested) is used.
- text = " ".join(f"word{i}" for i in range(30)) + "."
- chunks = split_into_chunks(text, max_words=5000)
- self.assertEqual(len(chunks), 1)
- self.assertEqual(len(chunks[0].split()), 30)
-
- def test_default_uses_runtime_config_chunk_size(self):
- # The default resolves config.CHUNK_SIZE at call time, so
- # patching the config changes the default split size.
- sentences = " ".join(
- f"S{i} " + " ".join(["word"] * 8) + "." for i in range(60))
- with patch.object(config, "CHUNK_SIZE", 120):
- chunks = split_into_chunks(sentences)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(chunk.split()) <= 120 for chunk in chunks))
-
-
-class SplitIntoChunksTests(unittest.TestCase):
- def test_empty_input(self):
- self.assertEqual(split_into_chunks(""), [])
- self.assertEqual(split_into_chunks(" \n "), [])
-
- def test_short_text_single_chunk(self):
- self.assertEqual(split_into_chunks("One short sentence."), ["One short sentence."])
-
- def test_respects_word_limit_across_sentences(self):
- # 10 sentences of 9 words each = 90 words total
- sentences = [f"S{i} " + " ".join(["word"] * 8) + "." for i in range(10)]
- chunks = split_into_chunks(" ".join(sentences), max_words=25)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(c.split()) <= 25 for c in chunks))
- self.assertEqual(sum(len(c.split()) for c in chunks), 90)
-
- def test_long_sentence_split_keeps_punctuation(self):
- # 10 clauses of 5 words each, joined by comma+space
- sentence = ", ".join([" ".join(["w"] * 5) for _ in range(10)]) + "."
- chunks = split_into_chunks(sentence, max_words=12)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(c.split()) <= 12 for c in chunks))
- self.assertIn(",", chunks[0]) # commas retained for TTS prosody
-
- def test_clause_split_never_breaks_numbers(self):
- # Regression: the clause split used to fire at every comma even
- # without whitespace, mutating "1,000,000" into "1, 000, 000".
- sentence = ("There were exactly 1,000,000 soldiers marching at 12:30, "
- + "and they kept marching onward " * 30) + "endlessly."
- chunks = split_into_chunks(sentence, max_words=25)
- self.assertGreater(len(chunks), 1)
- joined = " ".join(chunks)
- self.assertIn("1,000,000", joined)
- self.assertIn("12:30", joined)
- self.assertNotIn("1, 000", joined)
- self.assertNotIn("000, 000", joined)
- self.assertNotIn("12: 30", joined)
-
- def test_clause_split_requires_whitespace_after_punctuation(self):
- # Run-on clauses without spaces after commas have no clause split
- # point, so the last-resort word-boundary split fires instead.
- # Tokens themselves (and numbers like "1,000,000") stay intact.
- sentence = ",".join([" ".join(["w"] * 5) for _ in range(10)]) + "."
- chunks = split_into_chunks(sentence, max_words=12)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(chunk.split()) <= 12 for chunk in chunks))
- tokens = sentence.replace(",", " , ").split()
- rejoined = " ".join(chunks).replace(",", " , ").split()
- self.assertEqual(rejoined, tokens)
-
- def test_single_oversized_sentence_is_word_split(self):
- # A punctuation-free sentence longer than the limit is split at word
- # boundaries so no single request exceeds the configured size.
- sentence = " ".join(["word"] * 30) + "."
- chunks = split_into_chunks(sentence, max_words=10)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks))
- self.assertEqual(sum(len(chunk.split()) for chunk in chunks), 30)
-
- def test_word_split_never_breaks_number_tokens(self):
- # Numbers and other punctuation-bearing tokens are single words and
- # must never be broken apart by the last-resort word split.
- sentence = ("There were exactly 1,000,000 soldiers marching at 12:30 "
- "and " + "they kept marching onward " * 20) + "endlessly."
- chunks = split_into_chunks(sentence, max_words=10)
- self.assertGreater(len(chunks), 1)
- joined = " ".join(chunks)
- self.assertIn("1,000,000", joined)
- self.assertIn("12:30", joined)
- self.assertNotIn("1, 000", joined)
- self.assertNotIn("12: 30", joined)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_cleaning.py b/tests/test_cleaning.py
deleted file mode 100644
index 41f4ed7..0000000
--- a/tests/test_cleaning.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""Tests for text and HTML cleaning."""
-
-import unittest
-
-from converter.extractors import clean_html, clean_text
-
-
-class CleanTextTests(unittest.TestCase):
- def test_empty_input(self):
- self.assertEqual(clean_text(""), "")
- self.assertEqual(clean_text(None), "")
-
- def test_collapses_whitespace(self):
- self.assertEqual(clean_text("a\n\n b \t c"), "a b c")
-
- def test_preserves_inline_numbers(self):
- self.assertEqual(clean_text("He was 42 years old."), "He was 42 years old.")
-
- def test_preserves_grouped_and_decimal_numbers(self):
- self.assertEqual(
- clean_text("Over 1,000 pages and 3.5 stars."),
- "Over 1,000 pages and 3.5 stars.",
- )
-
- def test_removes_standalone_page_numbers(self):
- self.assertEqual(
- clean_text("End of page.\n7\nNext page text."),
- "End of page. Next page text.",
- )
-
- def test_page_number_removal_leaves_single_spacing(self):
- result = clean_text("Chapter one\n\n12\n\nChapter two")
- self.assertEqual(result, "Chapter one Chapter two")
- self.assertNotIn(" ", result)
-
-
-class CleanHtmlTests(unittest.TestCase):
- def test_strips_tags(self):
- self.assertEqual(clean_html("<p>Hello <b>world</b></p>"), "Hello world")
-
- def test_removes_script_and_style(self):
- html = "<style>.x{color:red}</style><p>Text</p><script>var a=1;</script>"
- self.assertEqual(clean_html(html), "Text")
-
- def test_unescapes_entities(self):
- self.assertEqual(clean_html("Tom &amp; Jerry"), "Tom & Jerry")
-
- def test_empty(self):
- self.assertEqual(clean_html(""), "")
- self.assertEqual(clean_html(None), "")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_converter.py b/tests/test_converter.py
deleted file mode 100644
index 2fe0f5d..0000000
--- a/tests/test_converter.py
+++ /dev/null
@@ -1,619 +0,0 @@
-"""Tests for the audiobook converter orchestration helpers."""
-
-import io
-import logging
-import tempfile
-import time
-import unittest
-from contextlib import redirect_stdout
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from converter import config, tts
-from converter import converter as converter_mod
-from converter.converter import (
- AudiobookConverter,
- find_existing_outputs,
- prompt_overwrite,
- setup_logging,
-)
-
-
-class SanitizeFilenameTests(unittest.TestCase):
- def test_removes_invalid_characters(self):
- self.assertEqual(AudiobookConverter._sanitize_filename('A "bad" name: here'),
- "A bad name here")
-
- def test_collapses_whitespace(self):
- self.assertEqual(AudiobookConverter._sanitize_filename(" spaced\tout "), "spaced out")
-
- def test_empty_falls_back(self):
- self.assertEqual(AudiobookConverter._sanitize_filename("///"), "chapter")
-
-
-class ConfigurationValidationTests(unittest.TestCase):
- def test_invalid_voice_mode_rejected(self):
- with self.assertRaises(ValueError):
- AudiobookConverter(voice_mode="custon_voice")
-
- def test_nonpositive_speed_rejected(self):
- with self.assertRaises(ValueError):
- AudiobookConverter(speed=0)
-
- def test_unknown_format_rejected(self):
- with self.assertRaises(ValueError):
- AudiobookConverter(output_format="wma")
-
- def test_unknown_language_rejected(self):
- with self.assertRaises(ValueError):
- AudiobookConverter(language="klingon")
-
- def test_unknown_backend_rejected(self):
- with self.assertRaises(ValueError) as ctx:
- AudiobookConverter(backend="piper")
- self.assertIn("piper", str(ctx.exception))
- self.assertIn("audiocpp", str(ctx.exception))
-
- def test_language_defaults_to_config(self):
- with patch("converter.converter.QwenTTSClient") as mock_tts:
- AudiobookConverter(backend=tts.BACKEND_QWEN)
- self.assertEqual(mock_tts.call_args.kwargs["language"], config.LANGUAGE)
-
- def test_output_format_defaults_to_config(self):
- with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter(backend=tts.BACKEND_QWEN)
- self.assertEqual(converter.output_format, config.AUDIO_FORMAT)
-
- def test_language_normalized_before_tts_client(self):
- with patch("converter.converter.QwenTTSClient") as mock_tts:
- converter = AudiobookConverter(language="ja", backend=tts.BACKEND_QWEN)
- self.assertEqual(converter.language, "Japanese")
- self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese")
-
-
-class FindExistingOutputsTests(unittest.TestCase):
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- self._original = converter_mod.AUDIOBOOKS_FOLDER
- converter_mod.AUDIOBOOKS_FOLDER = self.folder
-
- def tearDown(self):
- converter_mod.AUDIOBOOKS_FOLDER = self._original
- self._tmp.cleanup()
-
- def _touch(self, name):
- path = self.folder / name
- path.write_bytes(b"x")
- return path
-
- def test_no_existing_output(self):
- self.assertEqual(find_existing_outputs("dune", "mp3"), [])
-
- def test_primary_output_detected(self):
- self._touch("dune.mp3")
- self.assertEqual([p.name for p in find_existing_outputs("dune", "mp3")],
- ["dune.mp3"])
-
- def test_chapter_and_speed_copies_detected(self):
- for name in ("dune_01_Dune.mp3", "dune_02_Barony.mp3", "dune_1.5x.mp3"):
- self._touch(name)
- self._touch("dune2_01.mp3") # different book stem; must not match
- found = [p.name for p in find_existing_outputs("dune", "mp3")]
- self.assertEqual(len(found), 3)
-
- def test_other_extensions_ignored(self):
- self._touch("dune.mp3")
- self.assertEqual(find_existing_outputs("dune", "m4b"), [])
-
- def test_glob_metacharacters_in_stem(self):
- self._touch("book [1].mp3")
- self._touch("book [1]_1.5x.mp3")
- found = [p.name for p in find_existing_outputs("book [1]", "mp3")]
- self.assertEqual(sorted(found), ["book [1].mp3", "book [1]_1.5x.mp3"])
-
- def test_narrator_named_outputs_detected(self):
- for name in ("dune_Vivian.mp3", "dune_Vivian_1.5.mp3", "dune_Vivian_01_Dune.mp3"):
- self._touch(name)
- found = [p.name for p in find_existing_outputs("dune_Vivian", "mp3")]
- self.assertEqual(len(found), 3)
-
- def test_legacy_outputs_without_narrator_ignored(self):
- self._touch("dune.mp3")
- self._touch("dune_1.5.mp3")
- self.assertEqual(find_existing_outputs("dune_Vivian", "mp3"), [])
-
-
-class NarratorTagTests(unittest.TestCase):
- def _converter(self, voice_mode, ref_audio=None, instructions=None):
- converter = AudiobookConverter.__new__(AudiobookConverter)
- converter.voice_mode = voice_mode
- converter.voice_clone_ref_audio = ref_audio
- converter.backend = tts.BACKEND_QWEN
- converter.voice = None
- converter.instructions = instructions
- return converter
-
- def test_custom_voice_uses_speaker_display_name(self):
- self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
- "Vivian")
-
- def test_multi_word_display_name_gets_underscores(self):
- with patch.object(config, "SPEAKER", "uncle_fu"):
- self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
- "Uncle_Fu")
-
- def test_clone_uses_reference_audio_stem(self):
- self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(),
- "ref")
-
- def test_clone_stem_spaces_become_underscores(self):
- self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/my voice.wav")._narrator_tag(),
- "my_voice")
-
- def test_invalid_characters_sanitized(self):
- self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/bad:name?.wav")._narrator_tag(),
- "bad_name")
-
- def test_empty_after_sanitize_falls_back(self):
- self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(),
- "narrator")
-
- def _audiocpp_converter(self, voice=None, instructions=None):
- converter = self._converter(tts.VOICE_MODE_CUSTOM,
- instructions=instructions)
- converter.backend = tts.BACKEND_AUDIOCPP
- converter.voice = voice
- return converter
-
- def test_audiocpp_design_run_uses_designed_tag(self):
- # An instruction without a voice (voice design, or instruction-
- # defined voices) must not be named after the built-in speaker.
- converter = self._audiocpp_converter(instructions="A warm narrator")
- self.assertEqual(converter._narrator_tag(), "designed")
-
- def test_audiocpp_instruction_with_voice_keeps_voice_tag(self):
- converter = self._audiocpp_converter(
- voice="narrator", instructions="Calm delivery")
- self.assertEqual(converter._narrator_tag(), "narrator")
-
- def test_audiocpp_speaker_mode_keeps_speaker_tag(self):
- converter = self._audiocpp_converter()
- self.assertEqual(converter._narrator_tag(), "Vivian")
-
- def test_preflight_design_run_uses_designed_tag(self):
- with tempfile.TemporaryDirectory() as books_tmp, \
- tempfile.TemporaryDirectory() as output_tmp:
- original = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
- converter_mod.BOOKS_FOLDER = Path(books_tmp)
- converter_mod.AUDIOBOOKS_FOLDER = Path(output_tmp)
- try:
- (converter_mod.BOOKS_FOLDER / "book.txt").write_text(
- "hello world", encoding="utf-8")
- with patch("builtins.input",
- side_effect=AssertionError("should not prompt")):
- _, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_AUDIOCPP, None, tts.VOICE_MODE_CUSTOM,
- None, "mp3", instructions="A warm narrator")
- self.assertEqual(planned, [(converter_mod.BOOKS_FOLDER / "book.txt",
- "book_designed")])
- finally:
- converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = original
-
-
-class ChapterDebugDirTests(unittest.TestCase):
- """Per-chapter debug subfolder naming (chunk numbering restarts per chapter)."""
-
- def test_none_when_not_debugging(self):
- self.assertIsNone(AudiobookConverter._chapter_debug_dir(None, 3, "The Trial"))
-
- def test_chapter_subfolder_named_by_index_and_title(self):
- book_dir = Path("debug") / "dune_Vivian"
- chapter_dir = AudiobookConverter._chapter_debug_dir(book_dir, 3, "The Trial")
- self.assertEqual(chapter_dir, book_dir / "03_The Trial")
-
- def test_untitled_chapter_uses_fallback(self):
- chapter_dir = AudiobookConverter._chapter_debug_dir(Path("d"), 1, "")
- self.assertEqual(chapter_dir, Path("d") / "01_chapter")
-
-
-class DebugDumpTests(unittest.TestCase):
- """--debug: per-chunk text/audio dumps and request/response logging."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._debug_folder = patch.object(converter_mod, "DEBUG_FOLDER", Path(self._tmp.name))
- self._debug_folder.start()
- self.debug_root = Path(self._tmp.name)
- self.converter = AudiobookConverter.__new__(AudiobookConverter)
- self.converter.client_chunks = True
- self.converter.tts = MagicMock()
-
- def tearDown(self):
- self._debug_folder.stop()
- self._tmp.cleanup()
-
- def _chunk_source(self, name, body=b"audio"):
- path = self.debug_root / "sources" / name
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(body)
- return path
-
- def test_successful_chunk_dumps_text_and_audio(self):
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.return_value = audio
- results = self.converter._synthesize_chunks(["Hello world."],
- debug_dir=self.debug_root / "book")
- self.assertEqual(results, {1: audio})
- debug_dir = self.debug_root / "book"
- self.assertEqual((debug_dir / "chunk_0001.txt").read_text(encoding="utf-8"),
- "Hello world.")
- self.assertEqual((debug_dir / "chunk_0001.wav").read_bytes(), b"audio")
-
- def test_failed_chunk_dumps_text_but_no_audio(self):
- self.converter.tts.process_chunk_with_retry.return_value = None
- results = self.converter._synthesize_chunks(["Hello again."],
- debug_dir=self.debug_root / "book")
- self.assertEqual(results, {1: None})
- debug_dir = self.debug_root / "book"
- self.assertEqual([path.name for path in sorted(debug_dir.iterdir())],
- ["chunk_0001.txt"])
-
- def test_text_dumped_even_when_request_raises(self):
- self.converter.tts.process_chunk_with_retry.side_effect = RuntimeError("boom")
- results = self.converter._synthesize_chunks(["Crash text."],
- debug_dir=self.debug_root / "book")
- self.assertEqual(results, {1: None})
- self.assertEqual((self.debug_root / "book" / "chunk_0001.txt").read_text(
- encoding="utf-8"), "Crash text.")
-
- def test_audio_suffix_preserved_and_nested_dirs_created(self):
- audio = self._chunk_source("generated.mp3")
- self.converter.tts.process_chunk_with_retry.return_value = audio
- self.converter._synthesize_chunks(["Hello."],
- debug_dir=self.debug_root / "nested" / "book")
- self.assertTrue((self.debug_root / "nested" / "book" / "chunk_0001.mp3").exists())
-
- def test_no_debug_dir_writes_nothing(self):
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.return_value = audio
- results = self.converter._synthesize_chunks(["Hello world."])
- self.assertEqual(results, {1: audio})
- self.assertEqual([path.name for path in self.debug_root.iterdir()], ["sources"])
-
- def test_request_and_response_are_logged(self):
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.return_value = audio
- with self.assertLogs("converter.converter", level="DEBUG") as logs:
- self.converter._synthesize_chunks(["Hello world."],
- debug_dir=self.debug_root / "book")
- joined = "\n".join(logs.output)
- self.assertIn("Chunk 1/1 request text: Hello world.", joined)
- self.assertIn("Chunk 1/1 response in", joined)
- self.assertIn("chunk_0001.wav", joined)
-
- def test_debug_write_failure_does_not_abort_conversion(self):
- blocker = self.debug_root / "blocker"
- blocker.write_bytes(b"")
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.return_value = audio
- results = self.converter._synthesize_chunks(["Hello."], debug_dir=blocker / "book")
- self.assertEqual(results, {1: audio})
-
- def test_failed_chunk_stops_remaining_chunks(self):
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.side_effect = [audio, None, audio]
- results = self.converter._synthesize_chunks(["One.", "Two.", "Three."])
- self.assertEqual(results, {1: audio, 2: None})
- self.assertEqual(self.converter.tts.process_chunk_with_retry.call_count, 2)
-
- def test_raising_chunk_stops_remaining_chunks(self):
- audio = self._chunk_source("chunk_0001.wav")
- self.converter.tts.process_chunk_with_retry.side_effect = [audio, RuntimeError("boom")]
- results = self.converter._synthesize_chunks(["One.", "Two.", "Three."])
- self.assertEqual(results, {1: audio, 2: None})
- self.assertEqual(self.converter.tts.process_chunk_with_retry.call_count, 2)
-
- def test_debug_flag_wiring(self):
- with patch("converter.converter.QwenTTSClient"):
- self.assertFalse(AudiobookConverter(backend=tts.BACKEND_QWEN).debug)
- self.assertTrue(AudiobookConverter(debug=True, backend=tts.BACKEND_QWEN).debug)
-
-
-class SetupLoggingTests(unittest.TestCase):
- """Console handler stays quiet; the log file keeps the full record."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._logs_folder = patch.object(converter_mod, "LOGS_FOLDER", Path(self._tmp.name))
- self._logs_folder.start()
- self._root = logging.getLogger()
- self._saved_handlers = self._root.handlers[:]
- self._saved_level = self._root.level
- self._saved_converter_level = logging.getLogger("converter").level
- self._root.handlers.clear()
-
- def tearDown(self):
- for handler in self._root.handlers:
- if handler not in self._saved_handlers:
- handler.close()
- self._root.handlers[:] = self._saved_handlers
- self._root.setLevel(self._saved_level)
- logging.getLogger("converter").setLevel(self._saved_converter_level)
- self._logs_folder.stop()
- self._tmp.cleanup()
-
- def _console_handler(self):
- matches = [h for h in logging.getLogger().handlers
- if isinstance(h, logging.StreamHandler)
- and not isinstance(h, logging.FileHandler)]
- self.assertEqual(len(matches), 1)
- return matches[0]
-
- def _file_handler(self):
- matches = [h for h in logging.getLogger().handlers
- if isinstance(h, logging.FileHandler)]
- self.assertEqual(len(matches), 1)
- return matches[0]
-
- def test_console_quiet_and_file_verbose_by_default(self):
- setup_logging()
- self.assertEqual(self._console_handler().level, logging.WARNING)
- self.assertEqual(self._file_handler().level, logging.INFO)
-
- def test_debug_flag_lowers_both_handlers(self):
- setup_logging(debug=True)
- self.assertEqual(self._console_handler().level, logging.DEBUG)
- self.assertEqual(self._file_handler().level, logging.DEBUG)
-
- def test_http_logs_filtered_from_console_only(self):
- setup_logging(debug=True)
- console = self._console_handler()
- http_record = logging.LogRecord("httpx", logging.INFO, "httpx", 1,
- "HTTP Request: GET ...", None, None)
- self.assertFalse(console.filter(http_record))
- chunk_record = logging.LogRecord("converter.converter", logging.DEBUG,
- "converter", 1,
- "Chunk 1/1 request text", None, None)
- self.assertTrue(console.filter(chunk_record))
-
-
-class SynthesizeChunkLoggingTests(unittest.TestCase):
- """Chunk failures surface as a single ERROR record (no print echo)."""
-
- def setUp(self):
- self.converter = AudiobookConverter.__new__(AudiobookConverter)
- self.converter.client_chunks = True
- self.converter.tts = MagicMock()
-
- def test_failed_chunk_logs_single_error(self):
- self.converter.tts.process_chunk_with_retry.return_value = None
- with self.assertLogs("converter.converter", level="ERROR") as logs:
- results = self.converter._synthesize_chunks(["Hello."])
- self.assertEqual(results, {1: None})
- self.assertEqual(len(logs.output), 1)
- self.assertIn("Chunk 1/1 failed", logs.output[0])
-
- def test_raising_chunk_logs_single_error(self):
- self.converter.tts.process_chunk_with_retry.side_effect = RuntimeError("boom")
- with self.assertLogs("converter.converter", level="ERROR") as logs:
- results = self.converter._synthesize_chunks(["Hello."])
- self.assertEqual(results, {1: None})
- self.assertEqual(len(logs.output), 1)
- self.assertIn("Chunk 1/1 error: boom", logs.output[0])
-
-
-class ServerSideChunkingOutputTests(unittest.TestCase):
- """With client-side chunking off (audiocpp default), the console skips
- the chunk vocabulary because the whole request is one server call."""
-
- def _converter(self, client_chunks: bool):
- converter = AudiobookConverter.__new__(AudiobookConverter)
- converter.client_chunks = client_chunks
- converter.backend = tts.BACKEND_AUDIOCPP
- converter.speed = 1.0
- converter.output_format = "mp3"
- converter.tts = MagicMock()
- converter.tts.process_chunk_with_retry.return_value = "chunk.wav"
- return converter
-
- def test_client_chunking_prints_chunk_progress(self):
- buf = io.StringIO()
- with redirect_stdout(buf):
- self._converter(client_chunks=True)._synthesize_chunks(["Hello."])
- out = buf.getvalue()
- self.assertIn("PROCESSING 1 CHUNKS", out)
- self.assertIn("Chunk 1/1 completed", out)
- self.assertIn("Successful: 1/1", out)
-
- def test_server_side_chunking_suppresses_chunk_output(self):
- buf = io.StringIO()
- with redirect_stdout(buf):
- self._converter(client_chunks=False)._synthesize_chunks(["Hello."])
- self.assertEqual(buf.getvalue(), "")
-
- def test_server_side_chunking_suppresses_chapter_chunk_suffix(self):
- buf = io.StringIO()
- with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
- redirect_stdout(buf):
- ok = self._converter(client_chunks=False)._convert_text(
- "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
- self.assertTrue(ok)
- out = buf.getvalue()
- self.assertIn("Chapter 2/5 converted", out)
- self.assertNotIn("chunk", out.lower())
-
- def test_single_request_run_notes_long_wait(self):
- buf = io.StringIO()
- with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
- redirect_stdout(buf):
- ok = self._converter(client_chunks=False)._convert_text(
- "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
- self.assertTrue(ok)
- out = buf.getvalue()
- self.assertIn("Sending the chapter 2/5 to the audio.cpp server as a "
- "single request", out)
- self.assertIn("expected for this to take a very long time", out)
-
- def test_client_chunking_run_keeps_chunk_phrasing(self):
- buf = io.StringIO()
- with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
- redirect_stdout(buf):
- ok = self._converter(client_chunks=True)._convert_text(
- "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
- self.assertTrue(ok)
- out = buf.getvalue()
- self.assertIn("Processing 1 chunks via audio.cpp server", out)
- self.assertNotIn("single request", out)
- self.assertIn("Chapter 2/5 converted (1/1 chunks)", out)
-
- def test_partial_chunks_abort_without_assembling(self):
- converter = self._converter(client_chunks=True)
- converter.tts.process_chunk_with_retry.side_effect = ["chunk_0001.wav", None]
- text = " ".join(f"word{i}" for i in range(8))
- with patch.object(config, "CHUNK_SIZE", 5), \
- patch.object(converter_mod.audio, "combine_chunks") as mock_combine:
- ok = converter._convert_text(text, Path("out.mp3"), time.time())
- self.assertFalse(ok)
- mock_combine.assert_not_called()
-
-
-class PromptOverwriteTests(unittest.TestCase):
- def test_single_file_yes(self):
- with patch("builtins.input", return_value="y"):
- self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
-
- def test_single_file_no(self):
- with patch("builtins.input", return_value="n"):
- self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
-
- def test_accepts_full_words(self):
- with patch("builtins.input", return_value="yes"):
- self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
- with patch("builtins.input", return_value="No"):
- self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
-
- def test_invalid_answer_reasked(self):
- with patch("builtins.input", side_effect=["maybe", "n"]) as mock_input:
- self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
- self.assertEqual(mock_input.call_count, 2)
-
- def test_empty_answer_defaults_yes(self):
- # Pressing Enter (empty input) accepts the default of yes, matching
- # the make_audiocpp_server_json tool's ask_bool(default=True) prompt.
- with patch("builtins.input", return_value=""):
- self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
-
- def test_eof_keeps_existing_output(self):
- with patch("builtins.input", side_effect=EOFError):
- self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
-
- def test_multiple_files_prompt_names_them(self):
- files = [Path("dune_01_Dune.mp3"), Path("dune_02_Barony.mp3")]
- with patch("builtins.input", return_value="y") as mock_input:
- self.assertTrue(prompt_overwrite(files, "dune"))
- prompt_text = mock_input.call_args[0][0]
- self.assertIn("2 output files for 'dune'", prompt_text)
- self.assertIn("dune_01_Dune.mp3", prompt_text)
- self.assertIn("overwrite them", prompt_text)
-
-
-class PreflightOverwritesTests(unittest.TestCase):
- """The pre-flight overwrite check runs without a TTS server connection."""
-
- def setUp(self):
- self._books_tmp = tempfile.TemporaryDirectory()
- self._output_tmp = tempfile.TemporaryDirectory()
- self._original_folders = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
- converter_mod.BOOKS_FOLDER = Path(self._books_tmp.name)
- converter_mod.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
- (converter_mod.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
-
- def tearDown(self):
- converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = self._original_folders
- self._books_tmp.cleanup()
- self._output_tmp.cleanup()
-
- def test_no_books_returns_empty(self):
- (converter_mod.BOOKS_FOLDER / "book.txt").unlink()
- with patch("builtins.input", side_effect=AssertionError("should not prompt")):
- book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
- self.assertEqual(book_files, [])
- self.assertEqual(planned, [])
-
- def test_new_book_planned_without_prompt(self):
- with patch("builtins.input", side_effect=AssertionError("should not prompt")):
- book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
- self.assertEqual(len(book_files), 1)
- self.assertEqual(planned, [(book_files[0], "book_Vivian")])
-
- def test_existing_output_enter_defaults_yes(self):
- (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
- with patch("builtins.input", return_value=""):
- book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
- self.assertEqual(planned, [(book_files[0], "book_Vivian")])
-
- def test_existing_output_declined_is_skipped(self):
- (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
- with patch("builtins.input", return_value="n"):
- book_files, planned = AudiobookConverter.preflight_overwrites(
- tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
- self.assertEqual(len(book_files), 1)
- self.assertEqual(planned, [])
-
-
-class RunOverwritePromptTests(unittest.TestCase):
- """The full run() flow: prompts collected before any conversion starts."""
-
- def setUp(self):
- self._books_tmp = tempfile.TemporaryDirectory()
- self._output_tmp = tempfile.TemporaryDirectory()
- self._original_folders = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
- converter_mod.BOOKS_FOLDER = Path(self._books_tmp.name)
- converter_mod.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
- (converter_mod.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
- self.converter = AudiobookConverter.__new__(AudiobookConverter)
- self.converter.voice_mode = tts.VOICE_MODE_CUSTOM
- self.converter.voice_clone_ref_audio = None
- self.converter.backend = tts.BACKEND_QWEN
- self.converter.voice = None
- self.converter.instructions = None
- self.converter.speed = 1.0
- self.converter.single_file = False
- self.converter.output_format = "mp3"
- self.converter.language = "English"
- self.converter.debug = False
- self.converted = []
- self.converter.convert_book = (
- lambda file_path, output_name=None:
- not self.converted.append((file_path.name, output_name)) or True)
-
- def tearDown(self):
- converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = self._original_folders
- self._books_tmp.cleanup()
- self._output_tmp.cleanup()
-
- def test_declined_book_is_skipped(self):
- (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
- with patch("builtins.input", return_value="n"):
- self.assertTrue(self.converter.run())
- self.assertEqual(self.converted, [])
- self.assertTrue((converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").exists())
-
- def test_accepted_book_is_converted(self):
- (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
- with patch("builtins.input", return_value="y"):
- self.assertTrue(self.converter.run())
- self.assertEqual(self.converted, [("book.txt", "book_Vivian")])
-
- def test_new_book_converted_without_prompt(self):
- with patch("builtins.input", side_effect=AssertionError("should not prompt")):
- self.assertTrue(self.converter.run())
- self.assertEqual(self.converted, [("book.txt", "book_Vivian")])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_cover.py b/tests/test_cover.py
deleted file mode 100644
index f19db5a..0000000
--- a/tests/test_cover.py
+++ /dev/null
@@ -1,189 +0,0 @@
-"""Tests for stdlib-only cover generation: PNG structure, gradient, text."""
-
-import random
-import struct
-import tempfile
-import unittest
-import zlib
-from pathlib import Path
-
-from converter.cover import (
- _random_light_color,
- _text_width,
- _wrap_title,
- generate_cover,
-)
-
-
-def _decode_png(data: bytes):
- """Parse a PNG into (width, height, rows of RGB tuples)."""
- assert data[:8] == b"\x89PNG\r\n\x1a\n", "bad PNG signature"
- pos = 8
- idat = b""
- width = height = None
- while pos < len(data):
- length, chunk_type = struct.unpack(">I4s", data[pos:pos + 8])
- chunk_data = data[pos + 8:pos + 8 + length]
- crc = struct.unpack(">I", data[pos + 8 + length:pos + 12 + length])[0]
- assert crc == zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF, "bad CRC"
- if chunk_type == b"IHDR":
- width, height, depth, color_type = struct.unpack(">IIBB", chunk_data[:10])
- assert depth == 8 and color_type == 2 # 8-bit RGB
- elif chunk_type == b"IDAT":
- idat += chunk_data
- pos += 12 + length
- raw = zlib.decompress(idat)
- stride = 1 + width * 3
- assert len(raw) == height * stride, "unexpected decompressed size"
- rows = []
- for y in range(height):
- row = raw[y * stride + 1:(y + 1) * stride]
- rows.append([tuple(row[x * 3:x * 3 + 3]) for x in range(width)])
- return width, height, rows
-
-
-def _black_pixels(rows):
- return sum(1 for row in rows for pixel in row if pixel == (0, 0, 0))
-
-
-class GenerateCoverTests(unittest.TestCase):
- def _write(self, title, width=120, height=180, seed=7):
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "cover.png"
- result = generate_cover(title, path, width=width, height=height, seed=seed)
- data = path.read_bytes()
- return result, data
-
- def test_valid_png_with_requested_dimensions(self):
- _, data = self._write("Hello")
- width, height, rows = _decode_png(data)
- self.assertEqual((width, height), (120, 180))
- self.assertEqual(len(rows), 180)
-
- def test_gradient_matches_seeded_colors(self):
- _, data = self._write("Hello", seed=42)
- width, height, rows = _decode_png(data)
- rng = random.Random(42)
- top = _random_light_color(rng)
- bottom = _random_light_color(rng)
- # Corners of the text-free top/bottom rows match the endpoints
- self.assertEqual(rows[0][0], top)
- self.assertEqual(rows[0][-1], top)
- self.assertEqual(rows[height - 1][0], bottom)
- self.assertEqual(rows[height - 1][-1], bottom)
-
- def test_gradient_colors_are_light(self):
- # Text-free bottom row: every channel must stay in pastel territory
- _, data = self._write("Hello", seed=1)
- _, height, rows = _decode_png(data)
- for channel in rows[height - 1][0]:
- self.assertGreaterEqual(channel, 90)
-
- def test_title_renders_black_pixels(self):
- _, data = self._write("Hello")
- _, _, rows = _decode_png(data)
- self.assertGreater(_black_pixels(rows), 50)
-
- def test_title_renders_white_pixels(self):
- _, data = self._write("Hello")
- _, _, rows = _decode_png(data)
- white = sum(1 for row in rows for pixel in row if pixel == (255, 255, 255))
- self.assertGreater(white, 50)
-
- def test_white_text_sits_on_black_stroke(self):
- # Directly above a white pixel row there must be a black stroke row:
- # sample white pixels and confirm black neighbors within stroke width.
- _, data = self._write("Hi", width=200, height=100, seed=3)
- _, _, rows = _decode_png(data)
- whites = [(x, y) for y, row in enumerate(rows)
- for x, pixel in enumerate(row) if pixel == (255, 255, 255)]
- self.assertTrue(whites)
- checked = near_stroke = 0
- for x, y in whites[::5]:
- neighborhood = []
- for dy in range(-3, 4):
- for dx in range(-3, 4):
- if 0 <= y + dy < len(rows) and 0 <= x + dx < len(rows[0]):
- neighborhood.append(rows[y + dy][x + dx])
- checked += 1
- if (0, 0, 0) in neighborhood:
- near_stroke += 1
- # Interior white pixels are surrounded by white; every sampled pixel
- # should still see stroke black within 3px (font strokes are 5-6 px thick)
- self.assertEqual(near_stroke, checked)
-
- def test_empty_title_renders_gradient_only(self):
- _, data = self._write("")
- _, _, rows = _decode_png(data)
- self.assertEqual(_black_pixels(rows), 0)
-
- def test_unrenderable_title_degrades_to_gradient(self):
- # CJK glyphs are not in the bitmap font; no crash, no text pixels
- _, data = self._write("书名")
- _, _, rows = _decode_png(data)
- self.assertEqual(_black_pixels(rows), 0)
-
- def test_write_failure_returns_none(self):
- result = generate_cover("Hello", Path("/nonexistent_dir/cover.png"))
- self.assertIsNone(result)
-
-
-class WrapTitleTests(unittest.TestCase):
- def test_short_title_one_line(self):
- self.assertEqual(len(_wrap_title("Dune", 500)), 1)
-
- def test_long_title_wraps(self):
- lines = _wrap_title("The Extremely Long Windy Title of a Very Long Book", 600)
- self.assertGreater(len(lines), 1)
- for line in lines:
- self.assertLessEqual(_text_width(line), 600)
-
- def test_single_long_word_kept_intact(self):
- lines = _wrap_title("Antidisestablishmentarianism", 10)
- self.assertEqual(lines, ["Antidisestablishmentarianism"])
-
- def test_empty_title_no_lines(self):
- self.assertEqual(_wrap_title("", 500), [])
-
-
-class TextWidthTests(unittest.TestCase):
- def test_empty(self):
- self.assertEqual(_text_width(""), 0)
-
- def test_single_char_is_scaled_glyph(self):
- self.assertEqual(_text_width("A"), 30) # 5 px * scale 6
-
- def test_chars_include_spacing(self):
- self.assertEqual(_text_width("AB"), 66) # (2 glyphs * 6 - 1) * 6
-
-
-class DropShadowTests(unittest.TestCase):
- def _cover_rows(self, title, seed=7):
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "cover.png"
- generate_cover(title, path, width=200, height=200, seed=seed)
- return _decode_png(path.read_bytes())[2]
-
- def test_shadow_pixels_survive_next_to_text(self):
- rows = self._cover_rows("Hi")
- # The shadow lives down-right of the glyphs: there must be darkened
- # (but not pure black, not full-brightness) pixels beyond the text
- # block's bottom edge.
- blacks = {(x, y) for y, row in enumerate(rows)
- for x, pixel in enumerate(row) if pixel == (0, 0, 0)}
- self.assertTrue(blacks, "no text rendered")
- text_bottom = max(y for _, y in blacks)
- darkened = [pixel for y, row in enumerate(rows)
- if y > text_bottom for pixel in row
- if pixel != (0, 0, 0) and max(pixel) < 130]
- self.assertTrue(darkened, "no shadow pixels below the text")
-
- def test_empty_title_has_no_shadow(self):
- rows = self._cover_rows("")
- for row in rows:
- for pixel in row:
- self.assertNotEqual(pixel, (0, 0, 0))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_extractors.py b/tests/test_extractors.py
deleted file mode 100644
index ae1794c..0000000
--- a/tests/test_extractors.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""Tests for file text extraction."""
-
-import tempfile
-import unittest
-from pathlib import Path
-
-from converter.extractors import extract_text
-
-
-class TxtExtractionTests(unittest.TestCase):
- def _extract(self, data: bytes) -> str:
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.txt"
- path.write_bytes(data)
- return extract_text(path)
-
- def test_utf8(self):
- self.assertEqual(self._extract("héllo wörld".encode("utf-8")), "héllo wörld")
-
- def test_utf16_with_bom(self):
- self.assertEqual(self._extract("héllo".encode("utf-16")), "héllo")
-
- def test_cp1252(self):
- self.assertEqual(self._extract("“quotes”".encode("cp1252")), "“quotes”")
-
- def test_latin1_fallback(self):
- # 0x81 is undefined in cp1252, forcing the latin-1 catch-all
- self.assertEqual(self._extract(b"caf\x81"), "caf\x81")
-
- def test_unsupported_format(self):
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.xyz"
- path.write_bytes(b"data")
- with self.assertRaises(ValueError):
- extract_text(path)
-
-
-def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
- ("Two", "Second chapter text."))) -> None:
- from ebooklib import epub
-
- book = epub.EpubBook()
- book.set_identifier("test-id")
- book.set_title("Test Book")
- book.set_language("en")
- book.add_author("Test Author")
-
- items = []
- for index, (title, text) in enumerate(chapters, 1):
- chapter = epub.EpubHtml(title=title, file_name=f"chap{index}.xhtml", lang="en")
- chapter.content = f"<html><body><p>{text}</p></body></html>"
- book.add_item(chapter)
- items.append(chapter)
-
- book.toc = tuple(items)
- book.spine = ["nav", *items]
- book.add_item(epub.EpubNcx())
- book.add_item(epub.EpubNav())
-
- epub.write_epub(str(path), book)
-
-
-class EpubExtractionTests(unittest.TestCase):
- def setUp(self):
- try:
- import ebooklib # noqa: F401
- except ImportError:
- self.skipTest("ebooklib not installed")
-
- def test_ebooklib_extraction(self):
- # Regression test: the ebooklib path used to silently return "" due to
- # isinstance(item, ebooklib.ITEM_DOCUMENT) (an int, not a class).
- from converter.extractors import _read_epub_ebooklib
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.epub"
- _build_test_epub(path)
- items = _read_epub_ebooklib(path)
-
- html = "\n".join(content for _, content in items)
- self.assertIn("First chapter text.", html)
- self.assertIn("Second chapter text.", html)
-
- def test_epub_extraction_follows_spine_order(self):
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.epub"
- _build_test_epub(path)
- text = extract_text(path)
-
- self.assertIn("First chapter text.", text)
- self.assertIn("Second chapter text.", text)
- self.assertLess(text.index("First chapter text."),
- text.index("Second chapter text."))
-
-
-class ExtractSectionsTests(unittest.TestCase):
- def setUp(self):
- try:
- import ebooklib # noqa: F401
- except ImportError:
- self.skipTest("ebooklib not installed")
-
- def test_epub_sections_split_on_chapters(self):
- from converter.extractors import extract_sections
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.epub"
- _build_test_epub(path)
- sections = extract_sections(path)
-
- self.assertEqual(len(sections), 2)
- self.assertEqual(sections[0].title, "One")
- self.assertEqual(sections[1].title, "Two")
- self.assertIn("First chapter text.", sections[0].text)
- self.assertIn("Second chapter text.", sections[1].text)
-
- def test_txt_is_single_section(self):
- from converter.extractors import extract_sections
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.txt"
- path.write_text("Hello world.", encoding="utf-8")
- sections = extract_sections(path)
-
- self.assertEqual(len(sections), 1)
- self.assertEqual(sections[0].title, "book")
- self.assertEqual(sections[0].text, "Hello world.")
-
- def test_single_chapter_epub_keeps_chapter_title(self):
- from converter.extractors import extract_sections
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.epub"
- _build_test_epub(path, chapters=(("Only", "Just one chapter."),))
- sections = extract_sections(path)
-
- self.assertEqual(len(sections), 1)
- self.assertEqual(sections[0].title, "Only")
- self.assertIn("Just one chapter.", sections[0].text)
-
-
-class ExtractBookTests(unittest.TestCase):
- def test_txt_falls_back_to_stem_and_blank_author(self):
- from converter.extractors import extract_book
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "mybook.txt"
- path.write_text("Hello world.", encoding="utf-8")
- book = extract_book(path)
-
- self.assertEqual(book.title, "mybook")
- self.assertEqual(book.author, "")
- self.assertEqual(len(book.sections), 1)
-
- def test_epub_metadata_harvested(self):
- try:
- import ebooklib # noqa: F401
- except ImportError:
- self.skipTest("ebooklib not installed")
- from converter.extractors import extract_book
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.epub"
- _build_test_epub(path)
- book = extract_book(path)
-
- self.assertEqual(book.title, "Test Book")
- self.assertEqual(book.author, "Test Author")
- self.assertEqual([s.title for s in book.sections], ["One", "Two"])
-
- def test_pdf_metadata_harvested(self):
- from converter.extractors import extract_book
-
- try:
- from pypdf import PdfWriter
- except ImportError:
- self.skipTest("pypdf not installed")
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "book.pdf"
- writer = PdfWriter()
- writer.add_metadata({"/Title": "PDF Title", "/Author": "PDF Author"})
- writer.add_blank_page(width=612, height=792)
- with open(path, "wb") as handle:
- writer.write(handle)
- book = extract_book(path)
-
- self.assertEqual(book.title, "PDF Title")
- self.assertEqual(book.author, "PDF Author")
- self.assertEqual(len(book.sections), 1)
-
- def test_pdf_without_metadata_falls_back(self):
- from converter.extractors import extract_book
-
- try:
- from pypdf import PdfWriter
- except ImportError:
- self.skipTest("pypdf not installed")
-
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "plain.pdf"
- writer = PdfWriter()
- writer.add_blank_page(width=612, height=792)
- with open(path, "wb") as handle:
- writer.write(handle)
- book = extract_book(path)
-
- self.assertEqual(book.title, "plain")
- self.assertEqual(book.author, "")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_hub.py b/tests/test_hub.py
deleted file mode 100644
index b3a17e8..0000000
--- a/tests/test_hub.py
+++ /dev/null
@@ -1,502 +0,0 @@
-"""Tests for the TUI hub (ui/hub.py) menu and helpers.
-
-The hub drives the same curses widgets as ui/tui.py, so these tests reuse
-the fake curses/screen from test_tui to run the menu without a terminal.
-"""
-
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from backends import BackendStatus, ServerSpec
-from tests.test_tui import FakeCurses, FakeScreen
-from ui import hub, tui
-
-
-class HubHelperTests(unittest.TestCase):
- """Pure helpers in hub.py (no curses)."""
-
- def test_is_float(self):
- self.assertTrue(hub._is_float("1.0"))
- self.assertTrue(hub._is_float("2"))
- self.assertFalse(hub._is_float("abc"))
- self.assertFalse(hub._is_float(""))
-
- def test_list_voices_from_dir(self):
- with __import__("tempfile").TemporaryDirectory() as td:
- d = Path(td)
- (d / "Narrator.wav").write_bytes(b"x")
- (d / "Alpha.WAV").write_bytes(b"x")
- (d / "notes.txt").write_bytes(b"x")
- voices = hub._list_voices(str(d))
- # Stems preserve case; sorting is case-insensitive.
- self.assertEqual(voices, ["Alpha", "Narrator"])
-
- def test_list_voices_missing_dir(self):
- self.assertEqual(hub._list_voices("/no/such/dir"), [])
-
- def test_status_mark(self):
- from backends import BackendStatus
- running = BackendStatus("k", "l", installed=True, configured=True,
- running=True)
- installed = BackendStatus("k", "l", installed=True,
- configured=False)
- none = BackendStatus("k", "l", installed=False, configured=False)
- # running beats installed (a server is up even if not configured);
- # only a backend that is neither installed nor running is dimmed.
- self.assertEqual(hub._status_mark(running),
- ("running", "ok", "body"))
- self.assertEqual(hub._status_mark(installed),
- ("installed", "warn", "body"))
- self.assertEqual(hub._status_mark(none),
- ("unavailable", "err", "dim"))
- self.assertEqual(hub._status_mark(None),
- ("unavailable", "err", "dim"))
-
-
-class HubMenuTests(unittest.TestCase):
- """Drive _hub_menu with a fake screen (no terminal)."""
-
- def setUp(self):
- tui._THEME.clear()
- self.curses = FakeCurses()
- from unittest.mock import patch as _patch
- self._patcher = _patch.dict("sys.modules", {"curses": self.curses})
- self._patcher.start()
- self.addCleanup(self._patcher.stop)
- self.addCleanup(tui._THEME.clear)
-
- def _none_status(self, key="k", label="l"):
- from backends import BackendStatus
- return BackendStatus(key, label, installed=False, configured=False)
-
- def test_quit_returns_none_when_no_backend(self):
- # No backends installed/running: menu is [Set up, 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)
- self.assertIsNone(result)
-
- def test_menu_has_only_setup_settings_and_quit_without_backends(self):
- # Capture the options handed to tui.menu: with nothing installed or
- # running, Convert/Configure must be absent.
- captured = {}
-
- def fake_menu(stdscr, title, options, **kwargs):
- captured["options"] = options
- return "quit"
-
- screen = FakeScreen()
- with patch.object(hub.tui, "menu", fake_menu), \
- patch.object(hub, "detect_all", return_value=[]):
- hub._hub_menu(screen)
- labels = [label for label, _ in captured["options"]]
- self.assertEqual(labels, ["Set up a backend...", "Settings...",
- "Quit"])
-
- def test_menu_has_all_six_when_one_installed(self):
- captured = {}
-
- def fake_menu(stdscr, title, options, **kwargs):
- captured["options"] = options
- captured["rows"] = kwargs.get("table_rows")
- return "quit"
-
- screen = FakeScreen()
- st = self._none_status("qwen", "qwen-tts")
- st.installed = True
- with patch.object(hub.tui, "menu", fake_menu), \
- patch.object(hub, "detect_all", return_value=[st]):
- hub._hub_menu(screen)
- labels = [label for label, _ in captured["options"]]
- self.assertEqual(
- labels,
- ["Convert books...", "Set up a backend...",
- "Configure a backend...", "Server...", "Settings...", "Quit"])
- # The status table is passed through, one row per backend.
- self.assertEqual(captured["rows"],
- [("qwen-tts", "installed", "warn", "body")])
-
- def test_table_dims_name_when_not_installed_and_not_running(self):
- captured = {}
-
- def fake_menu(stdscr, title, options, **kwargs):
- captured["rows"] = kwargs.get("table_rows")
- return "quit"
-
- screen = FakeScreen()
- dead = self._none_status("audiocpp", "audio.cpp")
- external = self._none_status("qwen", "qwen-tts")
- external.running = True
- with patch.object(hub.tui, "menu", fake_menu), \
- patch.object(hub, "detect_all",
- return_value=[dead, external]):
- hub._hub_menu(screen)
- # Unusable backend: dim name. Running-but-not-installed stays bright.
- self.assertEqual(
- captured["rows"],
- [("audio.cpp", "unavailable", "err", "dim"),
- ("qwen-tts", "running", "ok", "body")])
-
- def test_menu_has_all_six_when_one_running_only(self):
- # Running but not installed (an external server) still unlocks the
- # Convert/Configure/Server entries.
- captured = {}
-
- def fake_menu(stdscr, title, options, **kwargs):
- captured["options"] = options
- return "quit"
-
- screen = FakeScreen()
- st = self._none_status("qwen", "qwen-tts")
- st.running = True
- with patch.object(hub.tui, "menu", fake_menu), \
- patch.object(hub, "detect_all", return_value=[st]):
- hub._hub_menu(screen)
- labels = [label for label, _ in captured["options"]]
- self.assertEqual(
- labels,
- ["Convert books...", "Set up a backend...",
- "Configure a backend...", "Server...", "Settings...", "Quit"])
-
- def test_convert_with_no_available_backend_offers_setup(self):
- # One installed-but-not-ready backend → Convert is offered. The
- # convert menu lists no available backend, so only "Set up a
- # backend..." is shown; Enter selects it → setup menu lists 3
- # backends; Esc goes back → convert returns None → main menu loops.
- # Then quit: main menu now has 5 options, Quit is the 5th (Down x4).
- from backends import BackendInfo, BackendStatus
- none = BackendStatus("k", "l", installed=True, configured=False)
- infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none,
- lambda: 0),
- BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0),
- BackendInfo("faster", "faster", lambda: none, lambda: 0)]
- # installed=True so the main menu shows Convert; but ready/running
- # is False so the convert menu's available list is empty.
- statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
- configured=False),
- BackendStatus("qwen", "qwen-tts", installed=True,
- configured=False),
- BackendStatus("faster", "faster", installed=True,
- configured=False)]
- with patch.object(hub, "detect_all", return_value=statuses), \
- patch.object(hub, "REGISTRY", infos):
- # Convert(Enter), setup-entry(Enter), Esc on setup menu,
- # back at main menu -> Down x5 -> Enter (Quit; Settings sits
- # just before it).
- screen = FakeScreen(keys=[10, 10, 27,
- FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10])
- result = hub._hub_menu(screen)
- self.assertIsNone(result)
-
-
-class SelectSpecTests(unittest.TestCase):
- """_select_spec: mode-aware server selection (qwen has two servers)."""
-
- def _qwen_status(self):
- return BackendStatus(
- "qwen", "qwen-tts", installed=True, configured=True,
- servers=[ServerSpec("qwen-custom", "http://127.0.0.1:7860", []),
- ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])])
-
- def test_qwen_custom_mode(self):
- spec = hub._select_spec(self._qwen_status(), {"clone": None})
- self.assertEqual(spec.name, "qwen-custom")
-
- def test_qwen_clone_mode(self):
- spec = hub._select_spec(self._qwen_status(), {"clone": "ref.wav"})
- self.assertEqual(spec.name, "qwen-clone")
-
- def test_audiocpp_returns_single_spec(self):
- st = BackendStatus("audiocpp", "audio.cpp", installed=True,
- configured=True,
- servers=[ServerSpec("audiocpp", "http://x", [])])
- spec = hub._select_spec(st, {})
- self.assertEqual(spec.name, "audiocpp")
-
- def test_none_when_no_servers(self):
- st = BackendStatus("qwen", "qwen-tts", installed=False,
- configured=False)
- self.assertIsNone(hub._select_spec(st, {}))
-
-
-class RunConversionTests(unittest.TestCase):
- """_run_conversion: autostart, hint-when-manual, and stop-after."""
-
- def test_autostart_starts_server_then_converts(self):
- spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
- status = BackendStatus("qwen", "qwen-tts", installed=True,
- configured=True, running=False,
- servers=[spec])
- kwargs = {"autostart": "qwen-custom"}
- with patch.object(hub, "detect_all", return_value=[status]), \
- patch.object(hub, "_find_spec", return_value=spec), \
- patch.object(hub.servers, "start", return_value=True) as mk_start, \
- patch.object(hub.audiobook, "convert", return_value=0) as mk_conv, \
- patch("builtins.input", return_value="n") as mk_input, \
- patch.object(hub.servers, "stop") as mk_stop:
- hub._run_conversion("qwen", kwargs)
- mk_start.assert_called_once_with(spec)
- mk_conv.assert_called_once()
- # User declined stopping → stop not called.
- mk_stop.assert_not_called()
-
- def test_autostart_stop_when_user_says_yes(self):
- spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
- status = BackendStatus("qwen", "qwen-tts", installed=True,
- configured=True, running=False,
- servers=[spec])
- kwargs = {"autostart": "qwen-custom"}
- with patch.object(hub, "detect_all", return_value=[status]), \
- patch.object(hub, "_find_spec", return_value=spec), \
- patch.object(hub.servers, "start", return_value=True), \
- patch.object(hub.audiobook, "convert", return_value=0), \
- patch("builtins.input", return_value="y"), \
- patch.object(hub.servers, "stop") as mk_stop:
- hub._run_conversion("qwen", kwargs)
- mk_stop.assert_called_once_with("qwen-custom")
-
- def test_autostart_aborts_when_server_fails(self):
- spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
- status = BackendStatus("qwen", "qwen-tts", installed=True,
- configured=True, running=False,
- launch_hint="hint cmd", servers=[spec])
- kwargs = {"autostart": "qwen-custom"}
- with patch.object(hub, "detect_all", return_value=[status]), \
- patch.object(hub, "_find_spec", return_value=spec), \
- patch.object(hub.servers, "start", return_value=False), \
- patch.object(hub.audiobook, "convert") as mk_conv, \
- patch.object(hub.servers, "stop") as mk_stop:
- hub._run_conversion("qwen", kwargs)
- mk_conv.assert_not_called()
- mk_stop.assert_not_called()
-
- def test_no_autostart_prints_hint_when_not_running(self):
- status = BackendStatus("qwen", "qwen-tts", installed=True,
- configured=True, running=False,
- launch_hint="the-hint")
- with patch.object(hub, "detect_all", return_value=[status]), \
- patch.object(hub.audiobook, "convert", return_value=0) as mk_conv:
- hub._run_conversion("qwen", {})
- mk_conv.assert_called_once()
-
-
-class AddAutostartTests(unittest.TestCase):
- """_add_autostart: offers to start the server when it isn't running."""
-
- def setUp(self):
- tui._THEME.clear()
- self.curses = FakeCurses()
- self._patcher = patch.dict("sys.modules", {"curses": self.curses})
- self._patcher.start()
- self.addCleanup(self._patcher.stop)
- self.addCleanup(tui._THEME.clear)
-
- def _status(self):
- spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
- return BackendStatus("qwen", "qwen-tts", installed=True,
- configured=True, running=False,
- servers=[spec])
-
- def test_sets_autostart_when_user_confirms(self):
- screen = FakeScreen(keys=[10]) # Enter = Yes
- cmd = ("convert", "qwen", {"clone": None})
- with patch.object(hub, "detect_all", return_value=[self._status()]), \
- patch("backends.common.server_running", return_value=False):
- hub._add_autostart(screen, cmd, [self._status()])
- self.assertEqual(cmd[2]["autostart"], "qwen-custom")
-
- def test_no_autostart_when_server_already_running(self):
- screen = FakeScreen(keys=[10])
- cmd = ("convert", "qwen", {"clone": None})
- with patch.object(hub, "detect_all", return_value=[self._status()]), \
- patch("backends.common.server_running", return_value=True):
- hub._add_autostart(screen, cmd, [self._status()])
- self.assertNotIn("autostart", cmd[2])
-
-
-class SettingsTests(unittest.TestCase):
- """Settings menu: field collection, validation, config.py writing."""
-
- def test_write_config_preserves_comments_and_other_lines(self):
- import tempfile
- with tempfile.TemporaryDirectory() as td:
- path = Path(td) / "config.py"
- path.write_text(
- "# Default output options\n"
- 'AUDIO_FORMAT = "m4b"\n'
- 'AUDIO_BITRATE = "128k"\n'
- 'LANGUAGE = "English"\n'
- "\n"
- "CHUNK_SIZE = 250 # words per request\n",
- encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
- hub._write_config({"AUDIO_FORMAT": "mp3",
- "AUDIO_BITRATE": "192k",
- "LANGUAGE": "Japanese",
- "CHUNK_SIZE": 300})
- text = path.read_text(encoding="utf-8")
- self.assertEqual(
- text,
- "# Default output options\n"
- 'AUDIO_FORMAT = "mp3"\n'
- 'AUDIO_BITRATE = "192k"\n'
- 'LANGUAGE = "Japanese"\n'
- "\n"
- "CHUNK_SIZE = 300 # words per request\n")
-
- def test_write_config_missing_key_raises(self):
- import tempfile
- with tempfile.TemporaryDirectory() as td:
- path = Path(td) / "config.py"
- path.write_text("X = 1\n", encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
- with self.assertRaises(ValueError):
- hub._write_config({"AUDIO_FORMAT": "mp3"})
-
- def test_apply_settings_writes_and_reloads_in_memory(self):
- written = {}
-
- def fake_write(updates):
- written.update(updates)
-
- original = {name: getattr(hub.config, name) for name in
- ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE")}
- self.addCleanup(lambda: [setattr(hub.config, name, value)
- for name, value in original.items()])
- values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
- "language": "en", "chunk_size": "300"}
- with patch.object(hub, "_write_config", fake_write):
- hub._apply_settings(values)
- # Values are trimmed and language normalized to a display name.
- self.assertEqual(written, {"AUDIO_FORMAT": "ogg",
- "AUDIO_BITRATE": "192k",
- "LANGUAGE": "English",
- "CHUNK_SIZE": 300})
- # In-memory config is reloaded so this session sees the change.
- self.assertEqual(hub.config.AUDIO_FORMAT, "ogg")
- self.assertEqual(hub.config.AUDIO_BITRATE, "192k")
- self.assertEqual(hub.config.LANGUAGE, "English")
- self.assertEqual(hub.config.CHUNK_SIZE, 300)
-
- def test_apply_settings_rejects_bad_values(self):
- original = {name: getattr(hub.config, name) for name in
- ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE")}
- self.addCleanup(lambda: [setattr(hub.config, name, value)
- for name, value in original.items()])
- with patch.object(hub, "_write_config") as mk_write:
- with self.assertRaises(ValueError):
- hub._apply_settings({"audio_format": "m4b",
- "audio_bitrate": "128k",
- "language": "Klingon",
- "chunk_size": "250"})
- with self.assertRaises(ValueError):
- hub._apply_settings({"audio_format": "m4b",
- "audio_bitrate": "128k",
- "language": "English",
- "chunk_size": "0"})
- mk_write.assert_not_called()
-
- def test_field_validators(self):
- self.assertIsNone(hub._validate_bitrate("128k"))
- self.assertIsNotNone(hub._validate_bitrate(" "))
- self.assertIsNone(hub._validate_language("English"))
- self.assertIsNone(hub._validate_language("en"))
- self.assertIsNotNone(hub._validate_language("Klingon"))
- self.assertIsNone(hub._validate_chunk_size("250"))
- self.assertIsNotNone(hub._validate_chunk_size("abc"))
- self.assertIsNotNone(hub._validate_chunk_size("0"))
-
- def test_settings_menu_builds_form_and_saves(self):
- captured = {}
-
- def fake_form(stdscr, title, fields, back_value=None):
- captured["fields"] = fields
- return {"audio_format": "ogg", "audio_bitrate": "192k",
- "language": "English", "chunk_size": "300"}
-
- applied = []
-
- def fake_apply(values):
- applied.append(values)
-
- def fake_flash(stdscr, text, kind="warn"):
- captured["flash"] = (text, kind)
-
- 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)
- self.assertEqual([f["key"] for f in captured["fields"]],
- ["audio_format", "audio_bitrate", "language",
- "chunk_size"])
- kinds = {f["key"]: f["kind"] for f in captured["fields"]}
- self.assertEqual(kinds["audio_format"], "choice")
- self.assertEqual(kinds["audio_bitrate"], "text")
- self.assertEqual(applied, [{"audio_format": "ogg",
- "audio_bitrate": "192k",
- "language": "English",
- "chunk_size": "300"}])
- self.assertEqual(captured["flash"], ("Settings saved.", "ok"))
-
- def test_settings_menu_cancel_does_not_apply(self):
- def fake_form(stdscr, title, fields, back_value=None):
- return back_value # user pressed Cancel
-
- applied = []
-
- def fake_apply(values):
- applied.append(values)
-
- with patch.object(hub.tui, "form", fake_form), \
- patch.object(hub, "_apply_settings", fake_apply):
- hub._settings_menu(None)
- self.assertEqual(applied, [])
-
- def test_settings_menu_writes_config_end_to_end(self):
- import tempfile
- tui._THEME.clear()
- self.addCleanup(tui._THEME.clear)
- curses = FakeCurses()
- patcher = patch.dict("sys.modules", {"curses": curses})
- patcher.start()
- self.addCleanup(patcher.stop)
-
- original = {name: getattr(hub.config, name) for name in
- ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE")}
- self.addCleanup(lambda: [setattr(hub.config, name, value)
- for name, value in original.items()])
-
- with tempfile.TemporaryDirectory() as td:
- path = Path(td) / "config.py"
- path.write_text(
- "# Default output options\n"
- 'AUDIO_FORMAT = "m4b"\n'
- 'AUDIO_BITRATE = "128k"\n'
- 'LANGUAGE = "English"\n'
- "\n"
- "CHUNK_SIZE = 250\n",
- encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
- # Down to Chunk size, Enter -> editor, Ctrl-U + '300',
- # Enter; Tab -> Save, Enter; a key dismisses the flash.
- screen = FakeScreen(keys=[
- 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)
- text = path.read_text(encoding="utf-8")
- self.assertIn('AUDIO_FORMAT = "m4b"', text)
- self.assertIn("CHUNK_SIZE = 300", text)
- # The running session also picked up the change in-memory.
- self.assertEqual(hub.config.CHUNK_SIZE, 300)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_tts.py b/tests/test_tts.py
deleted file mode 100644
index a2df07f..0000000
--- a/tests/test_tts.py
+++ /dev/null
@@ -1,1513 +0,0 @@
-"""Tests for the TTS client wrappers (language handling and payloads)."""
-
-import io
-import json
-import tempfile
-import time
-import unittest
-import wave
-from contextlib import redirect_stdout
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from converter import config, tts
-from converter.converter import AudiobookConverter
-from converter.tts import (
- AudioCppTTSClient,
- FasterTTSClient,
- QwenTTSClient,
- normalize_language,
-)
-
-
-class NormalizeLanguageTests(unittest.TestCase):
- def test_display_names_case_insensitive(self):
- self.assertEqual(normalize_language("english"), "English")
- self.assertEqual(normalize_language("ENGLISH"), "English")
- self.assertEqual(normalize_language(" Japanese "), "Japanese")
-
- def test_auto_accepted(self):
- self.assertEqual(normalize_language("auto"), "Auto")
- self.assertEqual(normalize_language("Auto"), "Auto")
-
- def test_iso_aliases(self):
- self.assertEqual(normalize_language("en"), "English")
- self.assertEqual(normalize_language("ja"), "Japanese")
- self.assertEqual(normalize_language("zh"), "Chinese")
- self.assertEqual(normalize_language("ko"), "Korean")
- self.assertEqual(normalize_language("de"), "German")
- self.assertEqual(normalize_language("fr"), "French")
- self.assertEqual(normalize_language("ru"), "Russian")
- self.assertEqual(normalize_language("pt"), "Portuguese")
- self.assertEqual(normalize_language("es"), "Spanish")
- self.assertEqual(normalize_language("it"), "Italian")
-
- def test_all_supported_languages_round_trip(self):
- for name in tts.TTS_LANGUAGES:
- self.assertEqual(normalize_language(name.lower()), name)
-
- def test_unknown_language_rejected_with_guidance(self):
- with self.assertRaises(ValueError) as ctx:
- normalize_language("klingon")
- message = str(ctx.exception)
- self.assertIn("klingon", message)
- self.assertIn("English", message)
-
- def test_none_and_empty_rejected(self):
- with self.assertRaises(ValueError):
- normalize_language(None)
- with self.assertRaises(ValueError):
- normalize_language(" ")
-
-
-class QwenTTSClientLanguageTests(unittest.TestCase):
- """Language validation and defaults, without touching the network."""
-
- def _make_client(self, **kwargs):
- with patch.object(QwenTTSClient, "_connect"):
- return QwenTTSClient(**kwargs)
-
- def test_default_follows_config_for_each_mode(self):
- custom = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
- self.assertEqual(custom.language, config.LANGUAGE)
- clone = self._make_client(voice_mode=tts.VOICE_MODE_CLONE,
- voice_clone_ref_audio="ref.wav")
- self.assertEqual(clone.language, config.LANGUAGE)
-
- def test_explicit_language_normalized(self):
- client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM, language="ja")
- self.assertEqual(client.language, "Japanese")
-
- def test_invalid_language_fails_before_connect(self):
- with patch.object(QwenTTSClient, "_connect") as mock_connect:
- with self.assertRaises(ValueError):
- QwenTTSClient(language="klingon")
- mock_connect.assert_not_called()
-
-
-class SeedResolutionTests(unittest.TestCase):
- """CONSTANT_SEED: one seed per run, reused for every request, so the
- voice stays consistent across chunk boundaries (the servers
- re-sample the voice when the seed changes between generations)."""
-
- def _make_client(self, **kwargs):
- with patch.object(QwenTTSClient, "_connect"):
- return QwenTTSClient(**kwargs)
-
- def test_constant_seed_draws_one_nonnegative_seed(self):
- with patch.object(config, "CONSTANT_SEED", True), \
- patch.object(config, "SEED", -1):
- client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
- self.assertGreaterEqual(client._seed, 0)
-
- def test_explicit_seed_wins_over_constant_seed(self):
- with patch.object(config, "CONSTANT_SEED", True), \
- patch.object(config, "SEED", 42):
- client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
- self.assertEqual(client._seed, 42)
-
- def test_without_constant_seed_minus_one_is_forwarded(self):
- with patch.object(config, "CONSTANT_SEED", False), \
- patch.object(config, "SEED", -1):
- client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
- self.assertEqual(client._seed, -1)
-
- def test_resolved_seed_is_reused_across_requests(self):
- api_info = {
- "named_endpoints": {
- "/run_custom_voice": {
- "parameters": [{"parameter_name": "seed"}]
- }
- }
- }
- client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = tts.VOICE_MODE_CUSTOM
- client.language = "English"
- client._seed = 1234
- client.api_info = api_info
- client.client = MagicMock()
- client._generate_custom_voice("first text")
- client._generate_custom_voice("second text")
- seeds = [call.kwargs["seed"]
- for call in client.client.predict.call_args_list]
- self.assertEqual(seeds, [1234, 1234])
-
-
-class PayloadLanguageTests(unittest.TestCase):
- """The language must reach the API payload in every endpoint variant."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.ref_audio = Path(self._tmp.name) / "reference.wav"
- self.ref_audio.write_bytes(b"x")
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def _custom_client(self, language, endpoint, api_info=None):
- client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = tts.VOICE_MODE_CUSTOM
- client.language = language
- client._seed = config.SEED
- client.api_info = api_info if api_info is not None else {
- "named_endpoints": {endpoint: {}}
- }
- client.client = MagicMock()
- return client
-
- def _clone_client(self, language, endpoint, api_info=None, ref_text="hello"):
- client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = tts.VOICE_MODE_CLONE
- client.language = language
- client._seed = config.SEED
- client.voice_clone_ref_audio = str(self.ref_audio)
- client.voice_clone_ref_text = ref_text
- client.clone_api_info = api_info if api_info is not None else {
- "named_endpoints": {endpoint: {}}
- }
- client.clone_client = MagicMock()
- client._ref_audio_filedata = {"dummy": "payload"}
- return client
-
- def test_custom_voice_run_instruct_uses_language(self):
- client = self._custom_client("Japanese", "/run_instruct")
- client._generate_custom_voice("text")
- kwargs = client.client.predict.call_args.kwargs
- self.assertEqual(kwargs["lang_disp"], "Japanese")
-
- def test_custom_voice_alt_endpoint_uses_language(self):
- client = self._custom_client("French", "/run_custom_voice")
- client._generate_custom_voice("text")
- kwargs = client.client.predict.call_args.kwargs
- self.assertEqual(kwargs["language"], "French")
-
- def test_voice_clone_run_voice_clone_uses_language(self):
- client = self._clone_client("Japanese", "/run_voice_clone")
- client._generate_voice_clone("text")
- kwargs = client.clone_client.predict.call_args.kwargs
- self.assertEqual(kwargs["lang_disp"], "Japanese")
-
- def test_voice_clone_alt_endpoint_uses_language(self):
- client = self._clone_client("Korean", "/generate_voice_clone")
- client._generate_voice_clone("text")
- kwargs = client.clone_client.predict.call_args.kwargs
- self.assertEqual(kwargs["language"], "Korean")
-
- def test_voice_clone_alt_endpoint_includes_optional_params(self):
- api_info = {
- "named_endpoints": {
- "/generate_voice_clone": {
- "parameters": [
- {"parameter_name": "model_size"},
- {"parameter_name": "seed"},
- ]
- }
- }
- }
- client = self._clone_client("English", "/generate_voice_clone", api_info=api_info)
- client._generate_voice_clone("text")
- kwargs = client.clone_client.predict.call_args.kwargs
- self.assertEqual(kwargs["model_size"], tts.MODEL_SIZE)
- self.assertEqual(kwargs["seed"], config.SEED)
-
-
-class FasterTTSClientHealthTests(unittest.TestCase):
- """Connection behavior of the faster-qwen3-tts client."""
-
- def _health_response(self, model_loaded=True):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = json.dumps(
- {"status": "ok", "model_loaded": model_loaded}).encode("utf-8")
- return response
-
- def test_unreachable_server_raises_with_readme_pointer(self):
- import urllib.error
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=urllib.error.URLError("Connection refused")):
- with self.assertRaises(RuntimeError) as ctx:
- FasterTTSClient()
- message = str(ctx.exception)
- self.assertIn("not reachable", message)
- self.assertIn("README", message)
-
- def test_model_not_loaded_raises(self):
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._health_response(model_loaded=False)):
- with self.assertRaises(RuntimeError) as ctx:
- FasterTTSClient()
- self.assertIn("not loaded", str(ctx.exception))
-
- def test_healthy_server_defaults_from_config(self):
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._health_response()):
- client = FasterTTSClient()
- self.assertEqual(client.voice, config.FASTER_VOICE)
- self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/"))
-
- def test_explicit_voice_and_url_override_config(self):
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._health_response()):
- client = FasterTTSClient(voice="narrator", api_url="http://10.0.0.5:9000/")
- self.assertEqual(client.voice, "narrator")
- self.assertEqual(client.api_url, "http://10.0.0.5:9000")
-
-
-class FasterTTSClientGenerateTests(unittest.TestCase):
- """Chunk generation: sub-chunking, WAV output, retries, bookkeeping."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
- self._sleep = patch("converter.tts.time.sleep")
- self._sleep.start()
-
- def tearDown(self):
- self._sleep.stop()
- self._chunks.stop()
- self._tmp.cleanup()
-
- def _make_client(self):
- client = FasterTTSClient.__new__(FasterTTSClient)
- client.voice = "default"
- client.api_url = "http://127.0.0.1:8000"
- return client
-
- def _read_wav(self, path):
- with wave.open(str(path), "rb") as wav_file:
- return (wav_file.getnchannels(), wav_file.getsampwidth(),
- wav_file.getframerate(), wav_file.readframes(wav_file.getnframes()))
-
- def test_generate_chunk_writes_valid_wav(self):
- client = self._make_client()
- pcm = b"\x01\x00" * 100
- with patch.object(client, "_request_pcm", return_value=pcm):
- result = client.generate_chunk("Hello world.", 1)
- self.assertIsNotNone(result)
- path = Path(result)
- self.assertEqual(path.name, "chunk_0001.wav")
- channels, sampwidth, framerate, frames = self._read_wav(path)
- self.assertEqual(channels, 1)
- self.assertEqual(sampwidth, 2)
- self.assertEqual(framerate, tts.SAMPLE_RATE)
- self.assertEqual(frames, pcm)
-
- def test_long_text_is_subchunked_and_concatenated_in_order(self):
- client = self._make_client()
- sentences = [" ".join(f"word{i}" for i in range(6)) + "." for _ in range(3)]
- text = " ".join(sentences)
- pcm_parts = [b"\x01\x00" * 10, b"\x02\x00" * 20, b"\x03\x00" * 30]
- with patch.object(config, "CHUNK_SIZE", 10), \
- patch.object(client, "_request_pcm", side_effect=pcm_parts) as mock_pcm:
- result = client.generate_chunk(text, 1)
- self.assertEqual(mock_pcm.call_count, 3)
- _, _, _, frames = self._read_wav(Path(result))
- self.assertEqual(frames, b"".join(pcm_parts))
-
- def test_subchunk_size_follows_config_chunk_size(self):
- client = self._make_client()
- text = " ".join(f"word{i}" for i in range(8))
- pcm = b"\x01\x00" * 10
- with patch.object(config, "CHUNK_SIZE", 4), \
- patch.object(client, "_request_pcm", return_value=pcm) as mock_pcm:
- result = client.generate_chunk(text, 1)
- # The sub-chunk split follows config.CHUNK_SIZE, so the whole
- # (8-word) text needs two 4-word requests here.
- self.assertEqual(mock_pcm.call_count, 2)
- self.assertIsNotNone(result)
-
- def test_stale_chunk_files_are_removed(self):
- stale = Path(self._tmp.name) / "chunk_0001.mp3"
- stale.write_bytes(b"old")
- client = self._make_client()
- with patch.object(client, "_request_pcm", return_value=b"\x01\x00"):
- client.generate_chunk("Hello.", 1)
- remaining = sorted(path.name for path in Path(self._tmp.name).glob("chunk_0001.*"))
- self.assertEqual(remaining, ["chunk_0001.wav"])
-
- def test_transient_failure_is_retried(self):
- client = self._make_client()
- pcm = b"\x01\x00" * 10
- with patch.object(client, "_request_pcm",
- side_effect=[RuntimeError("boom"), pcm]) as mock_pcm:
- result = client.generate_chunk("Hello.", 1)
- self.assertIsNotNone(result)
- self.assertEqual(mock_pcm.call_count, 2)
-
- def test_empty_pcm_response_is_treated_as_failure(self):
- client = self._make_client()
- pcm = b"\x01\x00" * 10
-
- def _response(body):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = body
- return response
-
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=[_response(b""), _response(pcm)]) as mock_urlopen:
- result = client.generate_chunk("Hello.", 1)
- self.assertIsNotNone(result)
- self.assertEqual(mock_urlopen.call_count, 2)
- _, _, _, frames = self._read_wav(Path(result))
- self.assertEqual(frames, pcm)
-
- def test_exhausted_subchunk_retries_fail_the_chunk(self):
- client = self._make_client()
- with patch.object(client, "_request_pcm",
- side_effect=RuntimeError("down")) as mock_pcm:
- result = client.generate_chunk("Hello.", 1)
- self.assertIsNone(result)
- self.assertEqual(mock_pcm.call_count, config.MAX_RETRIES)
-
- def test_empty_text_fails_the_chunk(self):
- client = self._make_client()
- with patch.object(client, "_request_pcm") as mock_pcm:
- result = client.generate_chunk(" ", 1)
- self.assertIsNone(result)
- mock_pcm.assert_not_called()
-
- def test_request_payload_includes_voice_text_and_format(self):
- client = self._make_client()
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = b"\x01\x00" * 10
- with patch("converter.tts.urllib.request.urlopen",
- return_value=response) as mock_urlopen:
- pcm = client._request_pcm("Hello world.")
- self.assertEqual(pcm, b"\x01\x00" * 10)
- request = mock_urlopen.call_args[0][0]
- self.assertEqual(request.full_url, "http://127.0.0.1:8000/v1/audio/speech")
- payload = json.loads(request.data.decode("utf-8"))
- self.assertEqual(payload["input"], "Hello world.")
- self.assertEqual(payload["voice"], "default")
- self.assertEqual(payload["response_format"], "pcm")
-
- def test_full_length_pcm_passes(self):
- client = self._make_client()
- text = " ".join(f"word{i}" for i in range(12))
- # 12 words -> expected 4.8s, half is 2.4s -> 2.5s of audio passes.
- pcm = b"\x01\x00" * int(2.5 * tts.SAMPLE_RATE)
- with patch.object(client, "_request_pcm", return_value=pcm):
- result = client.generate_chunk(text, 1)
- self.assertIsNotNone(result)
-
-
-class QwenTTSClientGenerateTests(unittest.TestCase):
- """Qwen chunk generation: sub-request splitting and concatenation."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
-
- def tearDown(self):
- self._chunks.stop()
- self._tmp.cleanup()
-
- def _make_client(self):
- client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = tts.VOICE_MODE_CUSTOM
- return client
-
- @staticmethod
- def _write_wav(path: Path, frames: bytes) -> Path:
- with wave.open(str(path), "wb") as wav_file:
- wav_file.setnchannels(1)
- wav_file.setsampwidth(2)
- wav_file.setframerate(tts.SAMPLE_RATE)
- wav_file.writeframes(frames)
- return path
-
- def _read_wav_frames(self, path: Path) -> bytes:
- with wave.open(str(path), "rb") as wav_file:
- return wav_file.readframes(wav_file.getnframes())
-
- def test_single_request_copies_audio(self):
- client = self._make_client()
- source = self._write_wav(Path(self._tmp.name) / "server.wav", b"\x01\x00" * 50)
- with patch.object(client, "_generate_custom_voice",
- return_value=(str(source),)) as mock_generate:
- result = client.generate_chunk("Hello world.", 1)
- mock_generate.assert_called_once_with("Hello world.")
- path = Path(result)
- self.assertEqual(path.name, "chunk_0001.wav")
- self.assertEqual(self._read_wav_frames(path), b"\x01\x00" * 50)
-
- def test_oversized_input_is_split_and_concatenated_in_order(self):
- client = self._make_client()
- first = self._write_wav(Path(self._tmp.name) / "one.wav", b"\x01\x00" * 10)
- second = self._write_wav(Path(self._tmp.name) / "two.wav", b"\x02\x00" * 20)
- text = " ".join(f"word{i}" for i in range(12))
- with patch.object(config, "CHUNK_SIZE", 5), \
- patch.object(client, "_generate_custom_voice",
- side_effect=[(str(first),), (str(second),),
- (str(first),)]) as mock_generate:
- result = client.generate_chunk(text, 1)
- self.assertEqual(mock_generate.call_count, 3)
- path = Path(result)
- self.assertEqual(path.name, "chunk_0001.wav")
- self.assertEqual(self._read_wav_frames(path),
- b"\x01\x00" * 10 + b"\x02\x00" * 20 + b"\x01\x00" * 10)
- for call in mock_generate.call_args_list:
- self.assertLessEqual(len(call[0][0].split()), 5)
-
- def test_empty_text_fails_the_chunk(self):
- client = self._make_client()
- with patch.object(client, "_generate_custom_voice") as mock_generate:
- result = client.generate_chunk(" ", 1)
- self.assertIsNone(result)
- mock_generate.assert_not_called()
-
-
-class AudioCppTTSClientHealthTests(unittest.TestCase):
- """Connection behavior of the audio.cpp client."""
-
- @staticmethod
- def _json_response(payload):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = json.dumps(payload).encode("utf-8")
- return response
-
- def _get_responses(self, health=None, models=None, voices=None):
- """Side effect dispatching GET responses by URL."""
- def _dispatch(request, **_kwargs):
- url = request if isinstance(request, str) else request.full_url
- if url.endswith("/health"):
- return self._json_response(health if health is not None
- else {"status": "ok"})
- if url.endswith("/v1/models"):
- return self._json_response(models if models is not None else
- {"data": [{"id": config.AUDIOCPP_MODEL_ID}]})
- if "/v1/audio/voices" in url:
- if voices is Exception:
- raise Exception("voices endpoint down")
- return self._json_response(voices if voices is not None
- else {"voices": ["narrator"]})
- raise AssertionError(f"unexpected URL: {url}")
- return _dispatch
-
- def _client(self, voice=None, language=None, model_id=None, **kwargs):
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=self._get_responses(**kwargs)):
- return AudioCppTTSClient(voice=voice, language=language,
- model_id=model_id)
-
- def test_unreachable_server_raises_with_readme_pointer(self):
- import urllib.error
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=urllib.error.URLError("Connection refused")):
- with self.assertRaises(RuntimeError) as ctx:
- AudioCppTTSClient()
- message = str(ctx.exception)
- self.assertIn("not reachable", message)
- self.assertIn("README", message)
-
- def test_unhealthy_status_raises(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(health={"status": "starting"})
- self.assertIn("starting", str(ctx.exception))
-
- def test_unknown_model_id_raises_with_configured_ids(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(models={"data": [{"id": "pocket-tts"}, {"id": "other"}]})
- message = str(ctx.exception)
- self.assertIn(config.AUDIOCPP_MODEL_ID, message)
- self.assertIn("pocket-tts", message)
- self.assertIn("other", message)
-
- def test_healthy_server_speaker_mode_defaults(self):
- client = self._client()
- self.assertEqual(client.api_url, config.AUDIOCPP_API_URL.rstrip("/"))
- self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID)
- self.assertEqual(client.language, config.LANGUAGE)
- self.assertEqual(client.voice, "Vivian")
- self.assertFalse(client.preset_mode)
-
- def test_speaker_mode_uses_configured_speaker(self):
- with patch.object(config, "SPEAKER", "uncle_fu"):
- client = self._client()
- self.assertEqual(client.voice, "Uncle Fu")
-
- def test_preset_mode_uses_requested_voice(self):
- client = self._client(voice="narrator")
- self.assertEqual(client.voice, "narrator")
- self.assertTrue(client.preset_mode)
-
- def test_preset_mode_validates_voice_against_server_list(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(voice="ghost", voices={"voices": ["narrator", "obama"]})
- message = str(ctx.exception)
- self.assertIn("ghost", message)
- self.assertIn("narrator", message)
- self.assertIn("obama", message)
-
- def test_preset_mode_skips_validation_when_voices_endpoint_fails(self):
- client = self._client(voice="narrator", voices=Exception)
- self.assertEqual(client.voice, "narrator")
-
- def test_invalid_language_fails_before_connect(self):
- with patch("converter.tts.urllib.request.urlopen") as mock_urlopen:
- with self.assertRaises(ValueError):
- AudioCppTTSClient(language="klingon")
- mock_urlopen.assert_not_called()
-
- def test_explicit_language_normalized(self):
- client = self._client(language="ja")
- self.assertEqual(client.language, "Japanese")
-
- def test_seed_resolved_once_per_run(self):
- with patch.object(config, "CONSTANT_SEED", True), \
- patch.object(config, "SEED", -1):
- client = self._client()
- self.assertGreaterEqual(client._seed, 0)
-
- def test_preset_mode_routes_to_clone_model_when_configured(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
- client = self._client(
- voice="narrator",
- models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]})
- self.assertEqual(client.model_id, "qwen3-tts-clone")
-
- def test_preset_mode_falls_back_when_clone_model_not_on_server(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
- self.assertLogs("converter.tts", level="WARNING") as logs:
- client = self._client(
- voice="narrator",
- models={"data": [{"id": "qwen3-tts"}, {"id": "pocket-tts"}]})
- self.assertEqual(client.model_id, "qwen3-tts")
- self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
-
- def test_empty_model_id_auto_picks_single_server_entry(self):
- # A multi-model server used without editing config.py: an empty
- # --model resolves to the only hosted entry automatically.
- client = self._client(
- voice="narrator", model_id="",
- models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
- voices={"voices": ["narrator"]})
- self.assertEqual(client.model_id, "higgs")
-
- def test_empty_model_id_with_multiple_entries_requires_explicit_choice(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(
- voice="narrator", model_id="",
- models={"data": [{"id": "higgs"}, {"id": "voxcpm2"}]},
- voices={"voices": ["narrator"]})
- message = str(ctx.exception)
- self.assertIn("--model", message)
- self.assertIn("higgs", message)
- self.assertIn("voxcpm2", message)
-
- def test_model_id_override_reaches_request(self):
- # --model overrides AUDIOCPP_MODEL_ID for the run.
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"):
- client = self._client(
- voice="narrator", model_id="higgs",
- models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
- voices={"voices": ["narrator"]})
- self.assertEqual(client.model_id, "higgs")
-
- def test_clone_model_id_ignored_for_speaker_mode(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
- client = self._client(
- models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]})
- self.assertEqual(client.model_id, "qwen3-tts")
-
- def test_clone_model_id_equal_to_primary_is_noop(self):
- with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID",
- config.AUDIOCPP_MODEL_ID):
- client = self._client(voice="narrator")
- self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID)
-
- def test_preset_mode_with_clone_only_server_uses_clone_model(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
- client = self._client(
- voice="narrator",
- models={"data": [{"id": "qwen3-tts-clone"}]})
- self.assertEqual(client.model_id, "qwen3-tts-clone")
-
- def test_speaker_mode_with_clone_only_server_suggests_voice(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(models={"data": [{"id": "qwen3-tts-clone"}]})
- message = str(ctx.exception)
- self.assertIn("qwen3-tts", message)
- self.assertIn("--voice", message)
-
- def test_preset_mode_with_no_matching_model_lists_both_ids(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
- self.assertLogs("converter.tts", level="WARNING"):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(voice="narrator",
- models={"data": [{"id": "pocket-tts"}]})
- message = str(ctx.exception)
- self.assertIn("qwen3-tts", message)
- self.assertIn("qwen3-tts-clone", message)
- self.assertIn("pocket-tts", message)
-
-
-class AudioCppTaskDetectionTests(unittest.TestCase):
- """Task auto-detection (tts/clon/vdes) and voice design validation."""
-
- @staticmethod
- def _json_response(payload):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = json.dumps(payload).encode("utf-8")
- return response
-
- def _client(self, voice=None, instructions=None, request_options=None,
- models=None):
- if models is None:
- models = {"data": [{"id": config.AUDIOCPP_MODEL_ID,
- "family": "qwen3_tts"}]}
-
- def _dispatch(request, **_kwargs):
- url = request if isinstance(request, str) else request.full_url
- if url.endswith("/health"):
- return self._json_response({"status": "ok"})
- if url.endswith("/v1/models"):
- return self._json_response(models)
- if "/v1/audio/voices" in url:
- return self._json_response({"voices": ["narrator"]})
- raise AssertionError(f"unexpected URL: {url}")
-
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=_dispatch):
- return AudioCppTTSClient(voice=voice, instructions=instructions,
- request_options=request_options)
-
- def test_missing_task_falls_back_to_tts(self):
- # Servers that predate the task field hosted plain TTS models.
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]})
- self.assertEqual(client.task, tts.AUDIOCPP_TASK_TTS)
- self.assertFalse(client.design_mode)
-
- def test_task_detected_from_models_endpoint(self):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]},
- instructions="A warm adult narrator")
- self.assertEqual(client.task, tts.AUDIOCPP_TASK_VDES)
- self.assertTrue(client.design_mode)
-
- def test_clon_task_entry_connects_in_preset_mode(self):
- client = self._client(voice="narrator", models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "chatterbox",
- "task": "clon"}]})
- self.assertEqual(client.task, "clon")
- self.assertFalse(client.design_mode)
- self.assertTrue(client.preset_mode)
-
- def test_unsupported_task_rejected_with_available_entries(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_asr",
- "task": "asr"},
- {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]},
- instructions="unused")
- message = str(ctx.exception)
- self.assertIn("'asr'", message)
- self.assertIn("--model", message)
- self.assertIn("tts-1", message)
-
- def test_vdes_without_instructions_requires_description(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]})
- message = str(ctx.exception)
- self.assertIn("voice design", message)
- self.assertIn("--instructions", message)
-
- def test_vdes_with_voice_rejected(self):
- with self.assertRaises(RuntimeError) as ctx:
- self._client(voice="narrator", models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]},
- instructions="A warm adult narrator")
- self.assertIn("--voice", str(ctx.exception))
- self.assertIn("--instructions", str(ctx.exception))
-
- def test_vdes_with_instructions_connects_in_design_mode(self):
- buf = io.StringIO()
- with redirect_stdout(buf):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]},
- instructions="A warm adult narrator")
- self.assertTrue(client.design_mode)
- self.assertEqual(client.instructions, "A warm adult narrator")
- out = buf.getvalue()
- self.assertIn("voice design", out)
- self.assertIn("A warm adult narrator", out)
-
- def test_instructions_without_voice_on_generic_family_connects(self):
- # Families without built-in speakers can get their voice from the
- # instruction alone (e.g. OmniVoice voice design).
- buf = io.StringIO()
- with redirect_stdout(buf):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "omnivoice",
- "task": "tts"}]},
- instructions="female, young adult, moderate pitch")
- self.assertFalse(client.design_mode)
- self.assertTrue(client.instruction_voice)
- self.assertIn("instruction voice", buf.getvalue())
-
- def test_instructions_with_builtin_speaker_family_stays_speaker_mode(self):
- buf = io.StringIO()
- with redirect_stdout(buf):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "tts"}]},
- instructions="Very happy.")
- self.assertFalse(client.design_mode)
- self.assertFalse(client.instruction_voice)
- self.assertIn("speaker 'Vivian'", buf.getvalue())
-
- def test_config_instructions_used_when_flag_omitted(self):
- with patch.object(config, "AUDIOCPP_INSTRUCTIONS",
- "A calm elderly storyteller"):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]})
- self.assertEqual(client.instructions, "A calm elderly storyteller")
-
- def test_explicit_instructions_override_config_default(self):
- with patch.object(config, "AUDIOCPP_INSTRUCTIONS", "from config"):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
- "task": "vdes"}]},
- instructions="from flag")
- self.assertEqual(client.instructions, "from flag")
-
-
-class AudioCppFamilyDetectionTests(unittest.TestCase):
- """Family auto-detection and per-family adaptations."""
-
- @staticmethod
- def _json_response(payload):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = json.dumps(payload).encode("utf-8")
- return response
-
- def _client(self, voice="narrator", models=None):
- def _dispatch(request, **_kwargs):
- url = request if isinstance(request, str) else request.full_url
- if url.endswith("/health"):
- return self._json_response({"status": "ok"})
- if url.endswith("/v1/models"):
- return self._json_response(models)
- if "/v1/audio/voices" in url:
- return self._json_response({"voices": [voice] if voice else []})
- raise AssertionError(f"unexpected URL: {url}")
-
- with patch("converter.tts.urllib.request.urlopen",
- side_effect=_dispatch):
- return AudioCppTTSClient(voice=voice)
-
- def test_family_detected_from_models_endpoint(self):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]})
- self.assertEqual(client.family, "higgs_audio_tts")
- self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
-
- def test_missing_family_falls_back_to_qwen3_tts(self):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID}]})
- self.assertEqual(client.family, "qwen3_tts")
- self.assertTrue(client.profile.builtin_speakers)
-
- def test_unknown_family_uses_generic_profile(self):
- client = self._client(models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]})
- self.assertEqual(client.family, "future_tts")
- self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
- self.assertFalse(client.profile.builtin_speakers)
- self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT)
-
- def test_speaker_mode_rejected_for_clone_only_family(self):
- client = None
- try:
- client = self._client(voice=None, models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]})
- except RuntimeError as exc:
- message = str(exc)
- self.assertIn("voxcpm2", message)
- self.assertIn("--voice", message)
- self.assertIn("no built-in speakers", message)
- self.assertIsNone(client)
-
- def test_speaker_mode_allowed_for_qwen_family(self):
- client = self._client(voice=None, models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]})
- self.assertEqual(client.family, "qwen3_tts")
-
- def test_clone_model_id_of_different_family_is_ignored(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \
- self.assertLogs("converter.tts", level="WARNING") as logs:
- client = self._client(models={"data": [
- {"id": "higgs", "family": "higgs_audio_tts"},
- {"id": "qwen-clone", "family": "qwen3_tts"}]})
- self.assertEqual(client.model_id, "higgs")
- self.assertTrue(any("different family" in line.lower() or
- "hosts family" in line.lower()
- for line in logs.output))
-
- def test_clone_model_id_missing_on_non_qwen_server_is_debug_only(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \
- self.assertNoLogs("converter.tts", level="WARNING"):
- client = self._client(models={"data": [
- {"id": "higgs", "family": "higgs_audio_tts"}]})
- self.assertEqual(client.model_id, "higgs")
-
- def test_clone_model_id_missing_on_qwen_server_still_warns(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
- patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
- self.assertLogs("converter.tts", level="WARNING") as logs:
- client = self._client(models={"data": [
- {"id": "qwen3-tts", "family": "qwen3_tts"},
- {"id": "pocket-tts", "family": "pocket_tts"}]})
- self.assertEqual(client.model_id, "qwen3-tts")
- self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
-
- def test_iso_language_code_helper(self):
- self.assertEqual(tts.LANGUAGE_ISO_CODES["English"], "en")
- self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto"))
-
-
-class AudioCppTTSClientRequestTests(unittest.TestCase):
- """The /v1/audio/speech payload and response validation."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
- self._sleep = patch("converter.tts.time.sleep")
- self._sleep.start()
-
- def tearDown(self):
- self._sleep.stop()
- self._chunks.stop()
- self._tmp.cleanup()
-
- @staticmethod
- def _make_client(preset_mode=False, voice="Vivian", language="English", seed=-1,
- chunk_text=True, family="qwen3_tts", task="tts",
- instructions=None, request_options=None):
- client = AudioCppTTSClient.__new__(AudioCppTTSClient)
- client.api_url = "http://127.0.0.1:8080"
- client.model_id = config.AUDIOCPP_MODEL_ID
- client.preset_mode = preset_mode
- client.voice = voice
- client.language = language
- client._seed = seed
- client.chunk_text = chunk_text
- client.family = family
- client.task = task
- client.profile = tts.AUDIOCPP_FAMILY_PROFILES.get(
- family, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
- client.instructions = instructions or ""
- client.request_options = dict(request_options or {})
- client.design_mode = task == tts.AUDIOCPP_TASK_VDES
- # Mirrors the connect-time rule: an instruction-defined voice on a
- # family without built-in speakers (design mode takes precedence).
- client.instruction_voice = (
- not preset_mode and not client.design_mode
- and not client.profile.builtin_speakers
- and bool(client.instructions))
- return client
-
- @staticmethod
- def _wav_bytes(frames=b"\x01\x00" * 10, rate=tts.SAMPLE_RATE):
- buffer = io.BytesIO()
- with wave.open(buffer, "wb") as wav_file:
- wav_file.setnchannels(1)
- wav_file.setsampwidth(2)
- wav_file.setframerate(rate)
- wav_file.writeframes(frames)
- return buffer.getvalue()
-
- def _post_response(self, body):
- response = MagicMock()
- response.__enter__.return_value = response
- response.read.return_value = body
- return response
-
- def test_payload_includes_model_input_voice_language_and_seed(self):
- client = self._make_client(preset_mode=True, voice="narrator",
- language="Japanese", seed=1234)
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello world.")
- request = mock_urlopen.call_args[0][0]
- self.assertEqual(request.full_url,
- "http://127.0.0.1:8080/v1/audio/speech")
- payload = json.loads(request.data.decode("utf-8"))
- self.assertEqual(payload["model"], config.AUDIOCPP_MODEL_ID)
- self.assertEqual(payload["input"], "Hello world.")
- self.assertEqual(payload["voice"], "narrator")
- self.assertEqual(payload["language"], "Japanese")
- self.assertEqual(payload["seed"], 1234)
- self.assertNotIn("instructions", payload)
-
- def test_negative_seed_omitted_from_payload(self):
- client = self._make_client(preset_mode=True, voice="narrator", seed=-1)
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello world.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("seed", payload)
-
- def test_whole_text_sent_as_one_request_without_client_chunking(self):
- client = self._make_client(chunk_text=False)
- # 9 words with CHUNK_SIZE=5 would split in two if client chunking
- # were on.
- text = " ".join(f"word{i}" for i in range(9))
- with patch.object(config, "CHUNK_SIZE", 5), \
- patch.object(client, "_request_wav",
- return_value=self._wav_bytes()) as mock_request:
- result = client.generate_chunk(text, 1)
- self.assertIsNotNone(result)
- self.assertEqual(mock_request.call_count, 1)
- self.assertEqual(mock_request.call_args[0][0], text)
-
- def test_single_request_timeout_scales_with_text_length(self):
- client = self._make_client(chunk_text=False)
- long_text = " ".join(f"word{i}" for i in range(1500)) # ~10 min of audio
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav(long_text)
- timeout = mock_urlopen.call_args[1]["timeout"]
- self.assertGreater(timeout, config.API_TIMEOUT)
-
- def test_client_chunking_keeps_configured_timeout(self):
- client = self._make_client(chunk_text=True)
- long_text = " ".join(f"word{i}" for i in range(1500))
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav(long_text)
- timeout = mock_urlopen.call_args[1]["timeout"]
- self.assertEqual(timeout, config.API_TIMEOUT)
-
- def test_speaker_mode_sends_instruct(self):
- client = self._make_client(preset_mode=False)
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["instructions"], config.INSTRUCT)
-
- def test_explicit_instructions_replace_config_instruct(self):
- # --instructions overrides the INSTRUCT default in speaker mode.
- client = self._make_client(preset_mode=False,
- instructions="Read whisper quiet.")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["instructions"], "Read whisper quiet.")
-
- def test_preset_mode_sends_instructions_alongside_voice(self):
- # Clone + style control: both the server-side voice and the
- # instruction reach the model.
- client = self._make_client(preset_mode=True, voice="narrator",
- instructions="Calm and steady.")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["voice"], "narrator")
- self.assertEqual(payload["instructions"], "Calm and steady.")
-
- def test_design_mode_payload_omits_voice_and_sends_instructions(self):
- client = self._make_client(task="vdes",
- instructions="A warm adult narrator")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("voice", payload)
- self.assertEqual(payload["instructions"], "A warm adult narrator")
-
- def test_design_mode_language_follows_family_profile(self):
- # The VoiceDesign package is family qwen3_tts, whose language field
- # takes Qwen display names like the other variants.
- client = self._make_client(task="vdes", language="Japanese",
- instructions="A warm adult narrator")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["language"], "Japanese")
-
- def test_instruction_voice_payload_omits_voice(self):
- # Instruction-defined voice on a family without built-in speakers:
- # no speaker name is invented, the instruction carries the voice.
- client = self._make_client(family="omnivoice",
- instructions="female, young adult")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("voice", payload)
- self.assertNotIn("language", payload) # generic profile: omitted
- self.assertEqual(payload["instructions"], "female, young adult")
-
- def test_request_options_forwarded_in_payload(self):
- client = self._make_client(preset_mode=True, voice="narrator",
- request_options={"emotion": "neutral",
- "speed": "1.1"})
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["options"], {"emotion": "neutral",
- "speed": "1.1"})
-
- def test_empty_request_options_omit_options_field(self):
- client = self._make_client(preset_mode=True, voice="narrator")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("options", payload)
-
- def test_generic_family_omits_language_and_instructions(self):
- # Clone-only families (higgs_audio_tts, voxcpm2, ...) detect the
- # language themselves and take no style instruction.
- client = self._make_client(preset_mode=False, family="higgs_audio_tts")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("language", payload)
- self.assertNotIn("instructions", payload)
-
- def test_iso_family_sends_language_code(self):
- client = self._make_client(preset_mode=True, voice="narrator",
- language="Japanese", family="index_tts2")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["language"], "ja")
-
- def test_iso_family_auto_omits_language(self):
- client = self._make_client(preset_mode=True, voice="narrator",
- language="Auto", family="index_tts2")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertNotIn("language", payload)
-
- def test_qwen_language_display_name_still_sent(self):
- client = self._make_client(preset_mode=True, voice="narrator",
- language="Japanese", family="qwen3_tts")
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
- client._request_wav("Hello.")
- payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
- self.assertEqual(payload["language"], "Japanese")
-
- def test_non_wav_response_rejected(self):
- client = self._make_client()
- for body in (b"", b"RIFFxxxx", b"MP3DATA-MP3DATA", b"RIFF\x00\x00\x00\x00mpeg"):
- with patch("converter.tts.urllib.request.urlopen",
- return_value=self._post_response(body)):
- with self.assertRaises(RuntimeError):
- client._request_wav("Hello.")
-
- def test_http_error_body_surfaced(self):
- import urllib.error
- client = self._make_client()
- error = urllib.error.HTTPError(
- "http://127.0.0.1:8080/v1/audio/speech", 500,
- "Server Error", {}, io.BytesIO(b'{"error":"bad voice"}'))
- with patch("converter.tts.urllib.request.urlopen", side_effect=error):
- with self.assertRaises(RuntimeError) as ctx:
- client._request_wav("Hello.")
- self.assertIn("500", str(ctx.exception))
- self.assertIn("bad voice", str(ctx.exception))
-
- def test_transient_failure_is_retried(self):
- client = self._make_client()
- wav = self._wav_bytes()
- with patch.object(client, "_request_wav",
- side_effect=[RuntimeError("boom"), wav]) as mock_request:
- result = client.generate_chunk("Hello.", 1)
- self.assertIsNotNone(result)
- self.assertEqual(mock_request.call_count, 2)
-
- def test_exhausted_retries_fail_the_chunk(self):
- client = self._make_client()
- with patch.object(client, "_request_wav",
- side_effect=RuntimeError("down")) as mock_request:
- result = client.generate_chunk("Hello.", 1)
- self.assertIsNone(result)
- self.assertEqual(mock_request.call_count, config.MAX_RETRIES)
-
- def test_empty_text_fails_the_chunk(self):
- client = self._make_client()
- with patch.object(client, "_request_wav") as mock_request:
- result = client.generate_chunk(" ", 1)
- self.assertIsNone(result)
- mock_request.assert_not_called()
-
- def test_generate_chunk_writes_valid_wav(self):
- client = self._make_client()
- frames = b"\x01\x00" * 100
- with patch.object(client, "_request_wav", return_value=self._wav_bytes(frames)):
- result = client.generate_chunk("Hello world.", 1)
- self.assertIsNotNone(result)
- path = Path(result)
- self.assertEqual(path.name, "chunk_0001.wav")
- with wave.open(str(path), "rb") as wav_file:
- self.assertEqual(wav_file.getnchannels(), 1)
- self.assertEqual(wav_file.getsampwidth(), 2)
- self.assertEqual(wav_file.getframerate(), tts.SAMPLE_RATE)
- self.assertEqual(wav_file.readframes(wav_file.getnframes()), frames)
-
- def test_long_text_is_subchunked_and_concatenated_in_order(self):
- client = self._make_client()
- sentences = [" ".join(f"word{i}" for i in range(6)) + "." for _ in range(3)]
- text = " ".join(sentences)
- parts = [self._wav_bytes(b"\x01\x00" * 10),
- self._wav_bytes(b"\x02\x00" * 20),
- self._wav_bytes(b"\x03\x00" * 30)]
- with patch.object(config, "CHUNK_SIZE", 10), \
- patch.object(client, "_request_wav", side_effect=parts) as mock_request:
- result = client.generate_chunk(text, 1)
- self.assertEqual(mock_request.call_count, 3)
- with wave.open(str(Path(result)), "rb") as wav_file:
- self.assertEqual(wav_file.readframes(wav_file.getnframes()),
- b"\x01\x00" * 10 + b"\x02\x00" * 20 + b"\x03\x00" * 30)
-
- def test_stale_chunk_files_are_removed(self):
- stale = Path(self._tmp.name) / "chunk_0001.mp3"
- stale.write_bytes(b"old")
- client = self._make_client()
- with patch.object(client, "_request_wav", return_value=self._wav_bytes()):
- client.generate_chunk("Hello.", 1)
- remaining = sorted(path.name for path in Path(self._tmp.name).glob("chunk_0001.*"))
- self.assertEqual(remaining, ["chunk_0001.wav"])
-
-
-class AudioCppHeartbeatTests(unittest.TestCase):
- """The heartbeat label drops 'Chunk' when the server does its own
- long-form chunking (chunk_text=False, the default)."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
-
- def tearDown(self):
- self._chunks.stop()
- self._tmp.cleanup()
-
- @staticmethod
- def _client(chunk_text):
- client = AudioCppTTSClient.__new__(AudioCppTTSClient)
- client.api_url = "http://127.0.0.1:8080"
- client.model_id = config.AUDIOCPP_MODEL_ID
- client.preset_mode = False
- client.voice = "Vivian"
- client.language = "English"
- client._seed = -1
- client.chunk_text = chunk_text
- client.family = "qwen3_tts"
- client.profile = tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE
- return client
-
- @staticmethod
- def _wav_bytes():
- buffer = io.BytesIO()
- with wave.open(buffer, "wb") as wav_file:
- wav_file.setnchannels(1)
- wav_file.setsampwidth(2)
- wav_file.setframerate(tts.SAMPLE_RATE)
- wav_file.writeframes(b"\x01\x00" * 10)
- return buffer.getvalue()
-
- def _run(self, chunk_text):
- client = self._client(chunk_text)
-
- def slow_request(*_args, **_kwargs):
- time.sleep(0.12)
- return self._wav_bytes()
-
- buf = io.StringIO()
- with patch.object(config, "HEARTBEAT_INTERVAL_SECONDS", 0.03), \
- patch.object(client, "_request_wav_with_retry",
- side_effect=slow_request), \
- redirect_stdout(buf):
- result = client.generate_chunk("Hello.", 1)
- self.assertTrue(result)
- return buf.getvalue()
-
- def test_server_side_chunking_heartbeat_has_no_chunk_word(self):
- out = self._run(chunk_text=False)
- self.assertIn("Request still generating", out)
- self.assertNotIn("Chunk", out)
-
- def test_client_side_chunking_heartbeat_keeps_chunk_word(self):
- out = self._run(chunk_text=True)
- self.assertIn("Chunk 1 still generating", out)
-
-
-class AudioCppTTSClientTruncationTests(unittest.TestCase):
- """Audio far shorter than its text implies fails the request."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
- self._chunks.start()
-
- def tearDown(self):
- self._chunks.stop()
- self._tmp.cleanup()
-
- def _make_client(self):
- client = AudioCppTTSClient.__new__(AudioCppTTSClient)
- client.api_url = "http://127.0.0.1:8080"
- client.model_id = config.AUDIOCPP_MODEL_ID
- client.preset_mode = True
- client.voice = "narrator"
- client.language = "English"
- client._seed = -1
- client.chunk_text = True
- client.family = "qwen3_tts"
- client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"]
- return client
-
- @staticmethod
- def _wav_bytes(frames):
- buffer = io.BytesIO()
- with wave.open(buffer, "wb") as wav_file:
- wav_file.setnchannels(1)
- wav_file.setsampwidth(2)
- wav_file.setframerate(tts.SAMPLE_RATE)
- wav_file.writeframes(frames)
- return buffer.getvalue()
-
- def test_full_length_wav_passes(self):
- client = self._make_client()
- text = " ".join(f"word{i}" for i in range(12))
- # 12 words -> expected 4.8s, half is 2.4s -> 2.5s of audio passes.
- wav = self._wav_bytes(b"\x01\x00" * int(2.5 * tts.SAMPLE_RATE))
- with patch.object(client, "_request_wav", return_value=wav):
- result = client.generate_chunk(text, 1)
- self.assertIsNotNone(result)
-
-
-class BackendWiringTests(unittest.TestCase):
- """AudiobookConverter wiring for the --backend selector."""
-
- def test_faster_backend_uses_faster_client_without_reference(self):
- with patch("converter.converter.FasterTTSClient") as mock_faster, \
- patch("converter.converter.QwenTTSClient") as mock_qwen, \
- patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_FASTER, voice="narrator")
- mock_faster.assert_called_once_with(voice="narrator")
- mock_qwen.assert_not_called()
- mock_audiocpp.assert_not_called()
-
- def test_audiocpp_backend_with_voice_uses_audiocpp_client(self):
- with patch("converter.converter.FasterTTSClient") as mock_faster, \
- patch("converter.converter.QwenTTSClient") as mock_qwen, \
- patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_AUDIOCPP, voice="narrator",
- language="ja")
- mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese",
- chunk_text=False, model_id=None,
- instructions=None,
- request_options={})
- mock_faster.assert_not_called()
- mock_qwen.assert_not_called()
-
- def test_audiocpp_backend_without_voice_uses_audiocpp_client(self):
- with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
- backend=tts.BACKEND_AUDIOCPP)
- mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE,
- chunk_text=False, model_id=None,
- instructions=None,
- request_options={})
-
- def test_audiocpp_backend_chunk_flag_forces_client_chunking(self):
- with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_AUDIOCPP,
- voice="narrator", chunk=True)
- mock_audiocpp.assert_called_once_with(voice="narrator",
- language=config.LANGUAGE,
- chunk_text=True, model_id=None,
- instructions=None,
- request_options={})
- self.assertTrue(converter.client_chunks)
-
- def test_audiocpp_backend_model_id_is_wired_through(self):
- with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_AUDIOCPP, voice="narrator",
- model_id="higgs")
- mock_audiocpp.assert_called_once_with(
- voice="narrator", language=config.LANGUAGE,
- chunk_text=False, model_id="higgs", instructions=None,
- request_options={})
-
- def test_audiocpp_backend_instructions_and_options_are_wired_through(self):
- with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
- backend=tts.BACKEND_AUDIOCPP,
- instructions="A warm adult narrator",
- request_options={"emotion": "neutral",
- "speed": "1.1"})
- mock_audiocpp.assert_called_once_with(
- voice=None, language=config.LANGUAGE,
- chunk_text=False, model_id=None,
- instructions="A warm adult narrator",
- request_options={"emotion": "neutral", "speed": "1.1"})
-
- def test_qwen_backend_uses_qwen_client(self):
- with patch("converter.converter.FasterTTSClient") as mock_faster, \
- patch("converter.converter.QwenTTSClient") as mock_qwen, \
- patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
- backend=tts.BACKEND_QWEN)
- mock_qwen.assert_called_once()
- mock_faster.assert_not_called()
- mock_audiocpp.assert_not_called()
-
- def test_qwen_clone_mode_still_requires_reference(self):
- with patch("converter.converter.QwenTTSClient"):
- with self.assertRaises(ValueError):
- AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_QWEN)
-
- def test_audiocpp_clone_mode_does_not_require_reference(self):
- # Cloning is server-side for the audiocpp backend, so the
- # clone-mode voice can be selected without local reference audio.
- with patch("converter.converter.AudioCppTTSClient"):
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_AUDIOCPP,
- voice="narrator")
- self.assertIsNone(converter.voice_clone_ref_audio)
-
- def test_chapter_chunks_audiocpp_default_is_one_request(self):
- converter = self._audiocpp_converter(voice="narrator")
- text = " ".join(f"word{i}" for i in range(50))
- with patch.object(config, "CHUNK_SIZE", 10):
- self.assertEqual(converter._chapter_chunks(text), [text])
-
- def test_chapter_chunks_audiocpp_chunk_flag_splits(self):
- with patch("converter.converter.AudioCppTTSClient"):
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_AUDIOCPP,
- voice="narrator", chunk=True)
- text = " ".join(f"word{i}" for i in range(50))
- with patch.object(config, "CHUNK_SIZE", 10):
- chunks = converter._chapter_chunks(text)
- self.assertGreater(len(chunks), 1)
- self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks))
-
- def test_chapter_chunks_qwen_always_splits(self):
- with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
- backend=tts.BACKEND_QWEN)
- text = " ".join(f"word{i}" for i in range(50))
- with patch.object(config, "CHUNK_SIZE", 10):
- chunks = converter._chapter_chunks(text)
- self.assertGreater(len(chunks), 1)
-
- def test_faster_backend_still_validates_other_settings(self):
- with patch("converter.converter.FasterTTSClient"):
- with self.assertRaises(ValueError):
- AudiobookConverter(backend=tts.BACKEND_FASTER, speed=0)
- with self.assertRaises(ValueError):
- AudiobookConverter(backend=tts.BACKEND_FASTER, language="klingon")
-
- def test_audiocpp_backend_still_validates_other_settings(self):
- with patch("converter.converter.AudioCppTTSClient"):
- with self.assertRaises(ValueError):
- AudiobookConverter(backend=tts.BACKEND_AUDIOCPP, speed=0)
- with self.assertRaises(ValueError):
- AudiobookConverter(backend=tts.BACKEND_AUDIOCPP, language="klingon")
-
- def _faster_converter(self, voice=None):
- with patch("converter.converter.FasterTTSClient"):
- return AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- backend=tts.BACKEND_FASTER, voice=voice)
-
- def _audiocpp_converter(self, voice=None):
- with patch("converter.converter.AudioCppTTSClient"):
- return AudiobookConverter(
- voice_mode=tts.VOICE_MODE_CLONE if voice else tts.VOICE_MODE_CUSTOM,
- backend=tts.BACKEND_AUDIOCPP, voice=voice)
-
- def test_narrator_tag_uses_faster_voice_name(self):
- converter = self._faster_converter(voice="male_richard_poe")
- self.assertEqual(converter._narrator_tag(), "male_richard_poe")
-
- def test_narrator_tag_falls_back_to_config_voice(self):
- converter = self._faster_converter()
- self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE)
-
- def test_narrator_tag_audiocpp_uses_voice_name(self):
- converter = self._audiocpp_converter(voice="female_narrator")
- self.assertEqual(converter._narrator_tag(), "female_narrator")
-
- def test_narrator_tag_audiocpp_falls_back_to_speaker(self):
- converter = self._audiocpp_converter()
- self.assertEqual(converter._narrator_tag(), "Vivian")
-
- def test_banner_and_narrator_work_without_reference_audio(self):
- converter = self._faster_converter(voice="male_richard_poe")
- converter._print_banner() # must not raise (regression: Path(None))
- self.assertIsNone(converter.voice_clone_ref_audio)
-
- def test_audiocpp_banner_prints_without_reference_audio(self):
- converter = self._audiocpp_converter(voice="narrator")
- converter._print_banner() # must not raise
- converter = self._audiocpp_converter()
- converter._print_banner()
-
- def test_audiocpp_banner_prints_model_family(self):
- from contextlib import redirect_stdout
- converter = self._audiocpp_converter(voice="narrator")
- converter.tts.family = "higgs_audio_tts"
- buffer = io.StringIO()
- with redirect_stdout(buffer):
- converter._print_banner()
- self.assertIn("higgs_audio_tts", buffer.getvalue())
-
- def test_non_faster_narrator_tag_unchanged(self):
- with tempfile.TemporaryDirectory() as tmp:
- ref = Path(tmp) / "ref.wav"
- ref.write_bytes(b"x")
- with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
- voice_clone_ref_audio=str(ref),
- backend=tts.BACKEND_QWEN)
- self.assertEqual(converter._narrator_tag(), "ref")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_tui.py b/tests/test_tui.py
deleted file mode 100644
index c121d55..0000000
--- a/tests/test_tui.py
+++ /dev/null
@@ -1,661 +0,0 @@
-"""Tests for the DOS-style curses TUI widgets in tui.py.
-
-The widget module imports curses lazily, so these tests swap the
-curses module for a small fake (patched into sys.modules) and drive
-the widgets with scripted keys against a recording screen. That works
-without a terminal and lets the tests assert exact drawing
-coordinates: theme colors, the left margin of list rows, and that no
-row ever paints over the dialog border.
-"""
-
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from ui import tui
-
-
-class FakeCurses:
- """Minimal curses stand-in: attributes, key codes, color pairs."""
-
- A_BOLD = 1
- A_DIM = 2
- A_REVERSE = 4
-
- COLOR_BLACK = 0
- COLOR_RED = 1
- COLOR_GREEN = 2
- COLOR_YELLOW = 3
- COLOR_BLUE = 4
- COLOR_MAGENTA = 5
- COLOR_CYAN = 6
- COLOR_WHITE = 7
-
- KEY_DOWN = 0x101
- KEY_UP = 0x102
- KEY_LEFT = 0x103
- KEY_RIGHT = 0x104
- KEY_HOME = 0x105
- KEY_END = 0x106
- KEY_PPAGE = 0x107
- KEY_NPAGE = 0x108
- KEY_BACKSPACE = 0x109
- KEY_BTAB = 0x10A
-
- ACS_ULCORNER = "ul"
- ACS_URCORNER = "ur"
- ACS_LLCORNER = "ll"
- ACS_LRCORNER = "lr"
- ACS_VLINE = "v"
- ACS_HLINE = "h"
-
- class error(Exception):
- pass
-
- def __init__(self):
- self.pairs = {} # pair number -> (fg, bg)
- self.colors = True
-
- def has_colors(self):
- return self.colors
-
- def start_color(self):
- pass
-
- def init_pair(self, number, fg, bg):
- self.pairs[number] = (fg, bg)
-
- def color_pair(self, number):
- return number << 8
-
- def curs_set(self, visibility):
- pass
-
- def endwin(self):
- pass
-
-
-class FakeScreen:
- """Recording curses window; getch() replays scripted keys."""
-
- def __init__(self, keys=(), width=80, height=24):
- self.keys = list(keys)
- self.width = width
- self.height = height
- self.strings = [] # (y, x, text, attr) from addstr
- self.chars = [] # (y, x, ch, attr) from addch
-
- def getmaxyx(self):
- return self.height, self.width
-
- def erase(self):
- pass
-
- def refresh(self):
- pass
-
- def bkgd(self, ch, attr):
- pass
-
- def addstr(self, y, x, text, attr=0):
- self.strings.append((y, x, text, attr))
-
- def addch(self, y, x, ch, attr=0):
- self.chars.append((y, x, ch, attr))
-
- def hline(self, y, x, ch, n, attr=0):
- pass
-
- def redrawwin(self):
- pass
-
- def getch(self):
- if not self.keys:
- raise AssertionError("the script ran out of keys")
- return self.keys.pop(0)
-
-
-class TuiTestCase(unittest.TestCase):
- """Base class: fresh theme + fake curses module for every test."""
-
- def setUp(self):
- tui._THEME.clear()
- self.curses = FakeCurses()
- self.screen = FakeScreen()
- patcher = patch.dict(sys.modules, {"curses": self.curses})
- patcher.start()
- self.addCleanup(patcher.stop)
- self.addCleanup(tui._THEME.clear)
-
- def dialog_box(self, screen=None):
- """(x0, x_right) border columns of the drawn dialog."""
- screen = screen or self.screen
- corners = screen.chars[:2]
- self.assertEqual(corners[0][2], FakeCurses.ACS_ULCORNER)
- self.assertEqual(corners[1][2], FakeCurses.ACS_URCORNER)
- return corners[0][1], corners[1][1]
-
- def assert_inside_border(self, screen=None):
- """No drawn string may reach the right border column."""
- screen = screen or self.screen
- _, x_right = self.dialog_box(screen)
- for _, x, text, _ in screen.strings:
- self.assertLessEqual(
- x + len(text), x_right,
- f"{text!r} painted over the border at x={x}")
-
-
-class ThemeTests(TuiTestCase):
- def test_desktop_and_message_backgrounds_are_black(self):
- frame = tui.Frame(self.screen, "Title", "footer")
- theme, pairs = frame.theme, self.curses.pairs
- for name in ("desktop", "border", "title", "ok", "warn", "err",
- "info", "input", "check", "accent"):
- fg, bg = pairs[theme[name] >> 8]
- self.assertEqual(bg, FakeCurses.COLOR_BLACK, name)
-
- def test_cursor_bar_and_active_button_stand_out(self):
- frame = tui.Frame(self.screen, "Title", "footer")
- theme, pairs = frame.theme, self.curses.pairs
- fg, bg = pairs[theme["bar"] >> 8]
- self.assertEqual((fg, bg),
- (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_CYAN))
- fg, bg = pairs[theme["btn_on"] >> 8]
- self.assertEqual((fg, bg),
- (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_GREEN))
-
- def test_without_colors_theme_uses_plain_attributes(self):
- self.curses.colors = False
- frame = tui.Frame(self.screen, "Title", "footer")
- self.assertEqual(self.curses.pairs, {})
- self.assertEqual(frame.theme["desktop"], 0)
- self.assertEqual(frame.theme["title"], FakeCurses.A_BOLD)
-
-
-class MenuTests(TuiTestCase):
- OPTIONS = [("first option", "one"), ("second option", "two")]
-
- def test_option_rows_left_justified_help_centered(self):
- screen = FakeScreen(keys=[10])
- value = tui.menu(screen, "Pick one", self.OPTIONS,
- help_lines=["Help text"])
- self.assertEqual(value, "one")
- x0, x_right = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- for label in ("first option", "second option"):
- x = next(x for _, x, text, _ in screen.strings if text == label)
- self.assertEqual(x, margin, label)
- inner_w = x_right - x0 - 1
- help_x = next(x for _, x, text, _ in screen.strings
- if text == "Help text")
- self.assertEqual(help_x, x0 + 1 + (inner_w - len("Help text")) // 2)
- self.assertGreater(help_x, margin)
- self.assert_inside_border(screen)
-
- def test_up_wraps_around_to_last_option(self):
- screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10])
- value = tui.menu(screen, "Pick one", self.OPTIONS)
- self.assertEqual(value, "two")
-
- def test_no_option_painted_over_the_border(self):
- screen = FakeScreen(keys=[FakeCurses.KEY_END, 10])
- tui.menu(screen, "Pick", [("a" * 60, "a"), ("b", "b")])
- self.assert_inside_border(screen)
-
- def test_empty_options_rejected(self):
- with self.assertRaises(ValueError):
- tui.menu(self.screen, "Pick", [])
-
- def test_esc_returns_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.menu(screen, "Pick", self.OPTIONS, back_value=marker),
- marker)
-
- def test_q_still_aborts_with_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q")])
- with self.assertRaises(tui.WizardCancelled):
- tui.menu(screen, "Pick", self.OPTIONS, back_value=marker)
-
-
-class MenuTableTests(TuiTestCase):
- """The optional status table: aligned columns and colored statuses."""
-
- ROWS = [("audio.cpp", "not installed", "err"),
- ("qwen-tts", "installed", "warn"),
- ("faster-qwen3-tts", "running", "ok")]
-
- def test_name_column_left_aligned_at_margin(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- x0, _ = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- for name, _, _ in self.ROWS:
- x = next(x for _, x, text, _ in screen.strings
- if text.rstrip() == name)
- self.assertEqual(x, margin, name)
-
- def test_status_column_aligned_at_one_fixed_offset(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- x0, _ = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- name_w = max(len(name) for name, _, _ in self.ROWS)
- expected_x = margin + name_w # the " status" segment starts here
- for _, status, _ in self.ROWS:
- x = next(x for _, x, text, _ in screen.strings
- if text.strip() == status)
- self.assertEqual(x, expected_x, status)
-
- def test_status_text_uses_the_theme_kind_color(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"],
- "ok": tui._THEME["ok"]}
- for _, status, kind in self.ROWS:
- attr = next(a for _, _, text, a in screen.strings
- if text.strip() == status)
- self.assertEqual(attr, want[kind], status)
-
- def test_optional_name_kind_colors_the_name_column(self):
- # 4-element rows: the 4th value is a theme kind for the name.
- rows = [("gone", "unavailable", "err", "dim"),
- ("here", "running", "ok", "body")]
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")], table_rows=rows)
- drawn = {text.rstrip(): attr for _, _, text, attr in screen.strings}
- self.assertEqual(drawn["gone"], tui._THEME["dim"])
- self.assertEqual(drawn["here"], tui._THEME["body"])
-
- def test_three_element_rows_default_to_body_names(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- for name, _, _ in self.ROWS:
- attr = next(a for _, _, text, a in screen.strings
- if text.rstrip() == name)
- self.assertEqual(attr, tui._THEME["body"], name)
-
- def test_table_title_is_dim_and_left_aligned(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- x0, _ = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- x, attr = next((x, a) for _, x, text, a in screen.strings
- if text == "Backend status")
- self.assertEqual(x, margin)
- self.assertEqual(attr, tui._THEME["dim"])
-
- def test_table_does_not_paint_over_the_border(self):
- screen = FakeScreen(keys=[10])
- tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
- self.assert_inside_border(screen)
-
-
-class ConfirmTests(TuiTestCase):
- def test_tab_switches_and_enter_activates(self):
- screen = FakeScreen(keys=[9, 10])
- self.assertFalse(tui.confirm(screen, "Overwrite?", default=True))
-
- def test_y_answers_directly(self):
- screen = FakeScreen(keys=[ord("y")])
- self.assertTrue(tui.confirm(screen, "Overwrite?", default=False))
-
- def test_enter_takes_the_default(self):
- screen = FakeScreen(keys=[10])
- self.assertTrue(tui.confirm(screen, "Overwrite?", default=True))
-
- def test_esc_aborts_without_cancel_value(self):
- screen = FakeScreen(keys=[27])
- with self.assertRaises(tui.WizardCancelled):
- tui.confirm(screen, "Overwrite?", default=True)
-
- def test_esc_returns_cancel_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.confirm(screen, "Overwrite?", default=True,
- cancel_value=marker),
- marker)
-
- def test_q_returns_cancel_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q")])
- self.assertIs(
- tui.confirm(screen, "Overwrite?", default=True,
- cancel_value=marker),
- marker)
-
-
-class LineEditTests(TuiTestCase):
- def test_typing_backspace_and_enter(self):
- keys = [ord("c"), ord("d"), FakeCurses.KEY_BACKSPACE, 10]
- screen = FakeScreen(keys=keys)
- value = tui.line_edit(screen, "Edit", "ab")
- self.assertEqual(value, "abc")
-
- def test_validation_error_keeps_editing(self):
- keys = [ord("x"), 10, FakeCurses.KEY_BACKSPACE, 10]
- screen = FakeScreen(keys=keys)
- value = tui.line_edit(
- screen, "Edit", "5",
- validate=lambda s: None if s.isdigit() else "digits only")
- self.assertEqual(value, "5")
-
- def test_esc_returns_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.line_edit(screen, "Edit", "text", back_value=marker),
- marker)
-
- def test_q_stays_typeable_with_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q"), 10])
- value = tui.line_edit(screen, "Edit", "te", back_value=marker)
- self.assertEqual(value, "teq")
-
-
-class FormTests(TuiTestCase):
- def _fields(self):
- # Fresh dicts each call: form() edits field values in place, and a
- # shared class attribute would leak edits between tests.
- return [
- {"key": "fmt", "label": "Format", "kind": "choice",
- "value": "m4b", "choices": ["mp3", "m4b", "ogg"]},
- {"key": "chunk", "label": "Chunk", "kind": "text",
- "value": "250"},
- ]
-
- def test_save_returns_current_values(self):
- screen = FakeScreen(keys=[9, 10]) # Tab -> Save, Enter
- result = tui.form(screen, "Settings", self._fields())
- self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
-
- def test_cancel_returns_back_value(self):
- marker = object()
- # Tab -> buttons, Left -> Cancel, Enter.
- screen = FakeScreen(keys=[9, FakeCurses.KEY_LEFT, 10])
- result = tui.form(screen, "Settings", self._fields(),
- back_value=marker)
- self.assertIs(result, marker)
-
- def test_esc_returns_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.form(screen, "Settings", self._fields(), back_value=marker),
- marker)
-
- def test_q_still_aborts_with_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q")])
- with self.assertRaises(tui.WizardCancelled):
- tui.form(screen, "Settings", self._fields(), back_value=marker)
-
- def test_choice_field_picks_another_value(self):
- # Enter opens the choice menu on 'm4b' (index 1); Down moves to
- # 'ogg', Enter accepts; Tab -> Save, Enter.
- screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
- result = tui.form(screen, "Settings", self._fields())
- self.assertEqual(result, {"fmt": "ogg", "chunk": "250"})
-
- def test_text_field_edits_then_saves(self):
- # Down to the text row, Enter opens the editor, type 'x', Enter,
- # then Tab -> Save, Enter.
- screen = FakeScreen(
- keys=[FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10])
- result = tui.form(screen, "Settings", self._fields())
- self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"})
-
- def test_validation_error_refuses_save_then_recovers(self):
- fields = [
- {"key": "chunk", "label": "Chunk", "kind": "text", "value": "bad",
- "validate": lambda s: None if s.isdigit() else "digits only"},
- {"key": "fmt", "label": "Format", "kind": "text", "value": "x"},
- ]
- # Tab->Save(Enter) fails, the flash consumes the next key; Enter
- # reopens the editor, Ctrl-U clears 'bad', type '120', Enter; then
- # Tab->Save(Enter).
- keys = [9, 10, 10, 10, 21, ord("1"), ord("2"), ord("0"), 10, 9, 10]
- screen = FakeScreen(keys=keys)
- result = tui.form(screen, "Settings", fields)
- self.assertEqual(result, {"chunk": "120", "fmt": "x"})
-
- def test_rows_left_justified_inside_the_border(self):
- screen = FakeScreen(keys=[9, 10])
- tui.form(screen, "Settings", self._fields())
- self.assert_inside_border(screen)
-
- def test_empty_fields_rejected(self):
- with self.assertRaises(ValueError):
- tui.form(self.screen, "Settings", [])
-
-
-def _accept_audio_cpp(entry: Path):
- """auto_select callback that accepts an 'audio.cpp' checkout root."""
- if entry.name == "audio.cpp" and (entry / "model_specs").is_dir():
- return entry
- return None
-
-
-class BrowseDirectoryTests(TuiTestCase):
- def setUp(self):
- super().setUp()
- tmp = tempfile.TemporaryDirectory()
- self.addCleanup(tmp.cleanup)
- self.root = Path(tmp.name)
- for name in ("alpha", "beta", "zulu"):
- (self.root / name).mkdir()
- (self.root / "noise.txt").write_text("x", encoding="utf-8")
-
- def _checkout_tree(self):
- """A temp dir containing an 'audio.cpp' checkout + a sibling dir."""
- tmp = tempfile.TemporaryDirectory()
- self.addCleanup(tmp.cleanup)
- root = Path(tmp.name)
- (root / "audio.cpp").mkdir()
- (root / "audio.cpp" / "model_specs").mkdir()
- (root / "other").mkdir()
- return root
-
- def test_listing_rows_left_justified(self):
- screen = FakeScreen(keys=[10])
- chosen = tui.browse_directory(screen, "Pick", start=self.root)
- self.assertEqual(chosen, self.root.resolve())
- x0, x_right = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- for label in ("[ Use this directory ]", "..",
- "alpha/", "beta/", "zulu/"):
- x = next(x for _, x, text, _ in screen.strings if text == label)
- self.assertEqual(x, margin, label)
- drawn = " ".join(text for _, _, text, _ in screen.strings)
- self.assertNotIn("noise.txt", drawn)
- self.assert_inside_border(screen)
-
- def test_enter_opens_highlighted_subdirectory(self):
- keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10, 10]
- screen = FakeScreen(keys=keys)
- chosen = tui.browse_directory(screen, "Pick", start=self.root)
- self.assertEqual(chosen, (self.root / "alpha").resolve())
- self.assert_inside_border(screen)
-
- def test_enter_auto_accepts_matching_subdir(self):
- root = self._checkout_tree()
- # sel 0 = [ Use this directory ], 1 = .., 2 = audio.cpp/
- keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]
- screen = FakeScreen(keys=keys)
- chosen = tui.browse_directory(screen, "Pick", start=root,
- auto_select=_accept_audio_cpp)
- self.assertEqual(chosen, (root / "audio.cpp").resolve())
-
- def test_right_auto_accepts_matching_subdir(self):
- root = self._checkout_tree()
- keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_RIGHT]
- screen = FakeScreen(keys=keys)
- chosen = tui.browse_directory(screen, "Pick", start=root,
- auto_select=_accept_audio_cpp)
- self.assertEqual(chosen, (root / "audio.cpp").resolve())
-
- def test_use_this_directory_ignores_auto_select(self):
- # Enter on '[ Use this directory ]' must accept the listed dir
- # without ever consulting auto_select.
- root = self._checkout_tree()
- calls = []
-
- def callback(entry):
- calls.append(entry)
- return entry # would auto-accept any subdir if consulted
-
- screen = FakeScreen(keys=[10])
- chosen = tui.browse_directory(screen, "Pick", start=root,
- auto_select=callback)
- self.assertEqual(chosen, root.resolve())
- self.assertEqual(calls, [])
-
- def test_auto_select_returning_none_descends_normally(self):
- # A non-matching subdir (or a None reply) keeps browsing: Enter
- # descends into it, then '[ Use this directory ]' accepts it.
- root = self._checkout_tree()
- calls = []
-
- def callback(entry):
- calls.append(entry)
- return None
-
- # sel 0 = use, 1 = .., 2 = audio.cpp/, 3 = other/
- keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10, 10]
- screen = FakeScreen(keys=keys)
- chosen = tui.browse_directory(screen, "Pick", start=root,
- auto_select=callback)
- self.assertEqual(chosen, (root / "other").resolve())
- # auto_select was consulted only for the highlighted 'other/' row.
- self.assertEqual([p.name for p in calls], ["other"])
-
- def test_esc_returns_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.browse_directory(screen, "Pick", start=self.root,
- back_value=marker),
- marker)
-
- def test_q_still_aborts_with_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q")])
- with self.assertRaises(tui.WizardCancelled):
- tui.browse_directory(screen, "Pick", start=self.root,
- back_value=marker)
-
-
-class CheckboxTreeTests(TuiTestCase):
- FAMILIES = [
- {"label": "Family one", "detail": "tts",
- "options": [{"key": "pkg-a", "label": "pkg-a", "recommended": True},
- {"key": "pkg-b", "label": "pkg-b", "recommended": False}]},
- {"label": "Family two", "detail": "tts, cloning",
- "options": [{"key": "pkg-c", "label": "pkg-c", "recommended": True}]},
- ]
-
- def test_nothing_selected_by_default(self):
- # Nothing is pre-checked: Enter alone flashes and waits, and a
- # selection only happens after Space checks an option. The first
- # Enter and the flash each consume a key.
- screen = FakeScreen(keys=[10, 10, ord(" "), 10])
- picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
- self.assertEqual(picked, [(0, "pkg-a")])
-
- def test_rows_left_justified_inside_the_border(self):
- screen = FakeScreen(keys=[ord(" "), 10])
- tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
- x0, x_right = self.dialog_box(screen)
- margin = x0 + 1 + tui.Frame.LIST_MARGIN
- strings = sorted((y, x, text) for y, x, text, _ in screen.strings)
- # Family row: its checkbox starts at the margin.
- y_family = next(y for y, _, text in strings if text == "- Family one")
- family_box_x = next(x for y, x, text in strings
- if y == y_family and text == "[x] ")
- self.assertEqual(family_box_x, margin)
- # Option row: its checkbox sits one indent (2 columns) deeper.
- option_box_x = next(x for y, x, text in strings
- if text == "[x] " and y != y_family)
- self.assertEqual(option_box_x, margin + 4)
- self.assert_inside_border(screen)
-
- def test_long_indented_options_do_not_paint_over_the_border(self):
- # A wide screen keeps the dialog width driven by the option row
- # itself (not the footer), the geometry where the old centered
- # segments drawing could paint over the right border.
- families = [{"label": "F", "detail": "tts",
- "options": [{"key": "long", "label": "x" * 60,
- "recommended": True}]}]
- screen = FakeScreen(keys=[ord(" "), 10], width=120)
- picked = tui.checkbox_tree(screen, "Pick", families)
- self.assertEqual(picked, [(0, "long")])
- self.assert_inside_border(screen)
-
- def test_space_checks_then_enter_accepts(self):
- screen = FakeScreen(keys=[ord(" "), 10])
- picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
- self.assertEqual(picked, [(0, "pkg-a")])
-
- def test_empty_families_rejected(self):
- with self.assertRaises(ValueError):
- tui.checkbox_tree(self.screen, "Pick", [])
-
- def test_esc_returns_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[27])
- self.assertIs(
- tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
- back_value=marker),
- marker)
-
- def test_q_still_aborts_with_back_value(self):
- marker = object()
- screen = FakeScreen(keys=[ord("q")])
- with self.assertRaises(tui.WizardCancelled):
- tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
- back_value=marker)
-
-
-class SuspendTests(TuiTestCase):
- """tui.suspend leaves curses, runs code, then repaints."""
-
- def test_suspend_runs_block_and_restores(self):
- ran = []
- with tui.suspend(self.screen):
- ran.append("inside")
- self.assertEqual(ran, ["inside"])
-
- def test_suspend_always_restores_on_exception(self):
- class Boom(Exception):
- pass
- with self.assertRaises(Boom):
- with tui.suspend(self.screen):
- raise Boom()
-
-
-class FlashTests(TuiTestCase):
- """tui.flash shows a notice until any key is pressed."""
-
- def test_notice_dismissed_by_any_key(self):
- screen = FakeScreen(keys=[10])
- # Should return (None) after consuming one key; not raise.
- tui.flash(screen, "a notice", kind="warn")
- self.assertEqual(screen.keys, [])
-
-
-if __name__ == "__main__":
- unittest.main()