aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 04:05:10 -0400
committerhistoria <historiavg@proton.me>2026-08-18 04:05:10 -0400
commit50f1825f05972e3685c55beb10c288899959b2e5 (patch)
treea5aa104d603ce9c710499184c133abe86497a16e /tests
parent86d2eb8d789f82dd8e56dd0ff53933152ba94e6b (diff)
downloadtts-audiobook-generator-50f1825f05972e3685c55beb10c288899959b2e5.tar.gz
feat: add metadata to audio files including generated cover art
Diffstat (limited to 'tests')
-rw-r--r--tests/cover_test.pngbin0 -> 6285 bytes
-rw-r--r--tests/gen_test_cover.py8
-rw-r--r--tests/test_audio.py135
-rw-r--r--tests/test_cover.py189
-rw-r--r--tests/test_extractors.py67
5 files changed, 399 insertions, 0 deletions
diff --git a/tests/cover_test.png b/tests/cover_test.png
new file mode 100644
index 0000000..c6c4bc6
--- /dev/null
+++ b/tests/cover_test.png
Binary files differ
diff --git a/tests/gen_test_cover.py b/tests/gen_test_cover.py
new file mode 100644
index 0000000..7c9347e
--- /dev/null
+++ b/tests/gen_test_cover.py
@@ -0,0 +1,8 @@
+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('Your Book Title Here',
+ Path(__file__).resolve().parent / 'cover_test.png')
+print('written:', p)
diff --git a/tests/test_audio.py b/tests/test_audio.py
index 7116e2b..97280ae 100644
--- a/tests/test_audio.py
+++ b/tests/test_audio.py
@@ -8,8 +8,11 @@ from pathlib import Path
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,
@@ -287,5 +290,137 @@ class BuildFFMetadataTests(unittest.TestCase):
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)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_cover.py b/tests/test_cover.py
new file mode 100644
index 0000000..f19db5a
--- /dev/null
+++ b/tests/test_cover.py
@@ -0,0 +1,189 @@
+"""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
index 7b307c0..d604351 100644
--- a/tests/test_extractors.py
+++ b/tests/test_extractors.py
@@ -43,6 +43,7 @@ def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
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):
@@ -138,5 +139,71 @@ class ExtractSectionsTests(unittest.TestCase):
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):
+ 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()