From 636a4523e640372e72e89a697c1a3821be7c609c Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 17 Aug 2026 18:32:39 -0400 Subject: test(converter): add tests --- tests/__init__.py | 0 tests/test_audio.py | 57 +++++++++++++++++++++++++++++ tests/test_chunking.py | 39 ++++++++++++++++++++ tests/test_cleaning.py | 54 ++++++++++++++++++++++++++++ tests/test_extractors.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_audio.py create mode 100644 tests/test_chunking.py create mode 100644 tests/test_cleaning.py create mode 100644 tests/test_extractors.py (limited to 'tests') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..a629224 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,57 @@ +"""Tests for audio helpers: speed parameters and chunk cleanup.""" + +import tempfile +import unittest +from pathlib import Path + +from converter import config +from converter.audio import cleanup_chunks, speed_export_params + + +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 = config.CHUNKS_FOLDER + config.CHUNKS_FOLDER = chunks_dir + try: + cleanup_chunks() + finally: + config.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()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_chunking.py b/tests/test_chunking.py new file mode 100644 index 0000000..da45f65 --- /dev/null +++ b/tests/test_chunking.py @@ -0,0 +1,39 @@ +"""Tests for text chunking.""" + +import unittest + +from converter.chunking import split_into_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 commas + 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_single_oversized_sentence_stays_intact(self): + sentence = " ".join(["word"] * 30) + "." + chunks = split_into_chunks(sentence, max_words=10) + self.assertEqual(chunks, [sentence]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cleaning.py b/tests/test_cleaning.py new file mode 100644 index 0000000..41f4ed7 --- /dev/null +++ b/tests/test_cleaning.py @@ -0,0 +1,54 @@ +"""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("

Hello world

"), "Hello world") + + def test_removes_script_and_style(self): + html = "

Text

" + self.assertEqual(clean_html(html), "Text") + + def test_unescapes_entities(self): + self.assertEqual(clean_html("Tom & 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_extractors.py b/tests/test_extractors.py new file mode 100644 index 0000000..c497267 --- /dev/null +++ b/tests/test_extractors.py @@ -0,0 +1,94 @@ +"""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) -> None: + from ebooklib import epub + + book = epub.EpubBook() + book.set_identifier("test-id") + book.set_title("Test Book") + book.set_language("en") + + chapter1 = epub.EpubHtml(title="One", file_name="chap1.xhtml", lang="en") + chapter1.content = "

First chapter text.

" + chapter2 = epub.EpubHtml(title="Two", file_name="chap2.xhtml", lang="en") + chapter2.content = "

Second chapter text.

" + + book.add_item(chapter1) + book.add_item(chapter2) + book.toc = (chapter1, chapter2) + book.spine = ["nav", chapter1, chapter2] + 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 _extract_epub_ebooklib + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.epub" + _build_test_epub(path) + text = _extract_epub_ebooklib(path) + + self.assertIn("First chapter text.", text) + self.assertIn("Second chapter text.", text) + + 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.")) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3