diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/cover_test.png | bin | 0 -> 6285 bytes | |||
| -rw-r--r-- | tests/gen_test_cover.py | 8 | ||||
| -rw-r--r-- | tests/test_audio.py | 135 | ||||
| -rw-r--r-- | tests/test_cover.py | 189 | ||||
| -rw-r--r-- | tests/test_extractors.py | 67 |
5 files changed, 399 insertions, 0 deletions
diff --git a/tests/cover_test.png b/tests/cover_test.png Binary files differnew file mode 100644 index 0000000..c6c4bc6 --- /dev/null +++ b/tests/cover_test.png 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() |
