diff options
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_chunking.py | 129 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 18 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 53 |
3 files changed, 197 insertions, 3 deletions
diff --git a/app/tests/test_chunking.py b/app/tests/test_chunking.py index 2904e40..bcae6b2 100644 --- a/app/tests/test_chunking.py +++ b/app/tests/test_chunking.py @@ -113,6 +113,135 @@ class SplitIntoChunksTests(unittest.TestCase): self.assertNotIn("1, 000", joined) self.assertNotIn("12: 30", joined) + def test_smart_strategy_distributes_sentences(self): + # The smart strategy leaves whole sentences intact: a chunk + # crossing the target (85% of the limit) ends at the next clean + # boundary early; legacy keeps packing until the limit. + sentences = " ".join( + " ".join(f"Sw{j}" for j in range(n)) + "." + for n in (9, 8, 3, 9, 8, 3)) + smart = split_into_chunks(sentences, max_words=20, smart=True) + legacy = split_into_chunks(sentences, max_words=20, smart=False) + self.assertEqual([len(c.split()) for c in smart], [17, 20, 3]) + self.assertEqual([len(c.split()) for c in legacy], [20, 20]) + self.assertEqual(self._content(smart), + self._content(sentences.split(" "))) + + def test_dialogue_tag_stays_with_its_quote(self): + # A closing quote followed by a lowercase attribution ("she + # said.") is one sentence unit: the chunk never separates them. + text = "“Come here!” she said. “Are you coming?” He did not answer." + chunks = split_into_chunks(text, max_words=10, smart=True) + self.assertIn("“Come here!” she said.", chunks[0]) + self._assert_bounded(chunks, 10) + self._assert_content(text, chunks) + + def test_abbreviations_and_initials_do_not_split(self): + text = ("Dr. Smith met J. K. R. at the U.S. border with No. 3 " + "shortly. He continued onward.") + chunks = split_into_chunks(text, max_words=50, smart=True) + self.assertEqual(len(chunks), 1) + self.assertIn("Dr. Smith", chunks[0]) + self.assertIn("J. K. R.", chunks[0]) + self.assertIn("U.S.", chunks[0]) + self._assert_content(text, chunks) + + def test_decimals_are_never_boundaries(self): + text = "Pi is 3.14159 and the toll was 1,000,000 miles. Next one." + chunks = split_into_chunks(text, max_words=12, smart=True) + joined = " ".join(chunks) + self.assertIn("3.14159", joined) + self.assertIn("1,000,000", joined) + self._assert_bounded(chunks, 12) + self._assert_content(text, chunks) + + def test_unclosed_quote_still_respects_the_limit(self): + # A quote with no closing mark must not grow chunks or silence + # later boundaries; units carry the open state and stay bounded. + text = "“No closing quote follows. " + "filler words here. " * 20 + chunks = split_into_chunks(text, max_words=10, smart=True) + self._assert_bounded(chunks, 10) + self._assert_content(text, chunks) + + def test_paragraph_boundary_resets_an_open_quote(self): + # Blank lines reset the quotation state: an unclosed quote in + # one paragraph cannot trap later paragraphs inside the quote. + text = ("“Still open. filler words inside the quote here.\n\n" + "New paragraph. Words outside any quote now, plenty.") + chunks = split_into_chunks(text, max_words=10, smart=True) + self._assert_bounded(chunks, 10) + self._assert_content(text, chunks) + second = [c for c in chunks if "New paragraph." in c] + self.assertTrue(second) + # The paragraph's text starts a fresh chunk: the open-quote + # prefix did not swallow it. + self.assertTrue( + any(chunk.startswith("New paragraph.") or + "“Still open." not in chunk + for chunk in chunks)) + + def test_quote_retreat_keeps_short_quotes_whole(self): + # When the next sentence would overflow, the break retreats to + # the last boundary that ended outside a quotation — before the + # quote — so a short quotation lands in one request. + text = ("Plain opening words here to fill. " + "“A short quote. With two sentences. Inside.” " + "More trailing prose follows this quote. And more.") + chunks = split_into_chunks(text, max_words=14, smart=True) + self._assert_bounded(chunks, 14) + self._assert_content(text, chunks) + quote_chunks = [c for c in chunks if "short quote" in c] + self.assertTrue(quote_chunks) + self.assertIn("Inside.", quote_chunks[0]) + + def test_oversized_single_tokens_respect_char_cap(self): + # Degenerate text (punctuation-free run): bounded by the hard + # character cap, with every character preserved. + text = "a" * 5000 + chunks = split_into_chunks(text, max_words=250, smart=True) + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(c) <= 250 * 8 for c in chunks)) + self.assertEqual("".join(chunks), text) + + def test_non_spaced_script_stays_bounded(self): + # CJK text: sentence terminators break units without + # whitespace, and each ideograph counts as a word. + text = "。".join(["字" * 15 for _ in range(30)]) + "。" + chunks = split_into_chunks(text, max_words=40, smart=True) + self._assert_bounded(chunks, 40) + self._assert_content(text, chunks) + joined = " ".join(chunks).split() + self.assertTrue(all(len(j) <= 33 for j in joined)) + + def test_legacy_and_smart_matching_smart_negation(self): + # smart=False preserves the legacy splitting exactly. + sentences = " ".join( + " ".join(f"Sw{j}" for j in range(9)) + "." for _ in range(10)) + with patch.object(config, "CHUNK_SIZE", 25): + legacy_default = split_into_chunks(sentences, smart=False) + self.assertEqual([len(c.split()) for c in legacy_default], + [18] * 5) + self.assertEqual(self._content(legacy_default), + self._content(sentences.split())) + + # -- helpers --------------------------------------------------------- + + @staticmethod + def _content(chunks_or_words): + """Non-whitespace content of the pieces (or of a token list).""" + pieces = (chunks_or_words if isinstance(chunks_or_words, str) + else " ".join(chunks_or_words)) + return "".join(pieces.split()) + + def _assert_content(self, text, chunks): + self.assertEqual(self._content(chunks), self._content(text)) + + def _assert_bounded(self, chunks, max_words): + from converter.chunking import _count_words + for chunk in chunks: + self.assertLessEqual(_count_words(chunk), max_words, + chunk[:40]) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 85ae6ca..9084898 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -585,6 +585,24 @@ class ChunkClampPromptTests(unittest.TestCase): with patch.object(config, "CHUNK_SIZE", 80): self.assertFalse(chunk_clamp_needed(self._entry())) + def test_per_run_clamp_caps_the_chapter_chunks(self): + # The popup's clamp caps the chapter chunks themselves, so each + # progress unit and debug dump spans one generation request. + converter = AudiobookConverter.__new__(AudiobookConverter) + converter.chunk_size = 4 + with patch.object(config, "CHUNK_SIZE", 250): + chunks = converter._chapter_chunks(" ".join(f"Sw{i} ." for i in range(12))) + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(c.split()) <= 4 for c in chunks)) + + def test_chapter_chunks_follow_chunk_size_without_a_clamp(self): + converter = AudiobookConverter.__new__(AudiobookConverter) + self.assertIsNone(converter.chunk_size) + with patch.object(config, "CHUNK_SIZE", 8): + chunks = converter._chapter_chunks(" ".join(f"Sw{i} ." for i in range(20))) + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(c.split()) <= 8 for c in chunks)) + def test_prompt_answers(self): entry = self._entry() with patch("builtins.input", return_value=""): diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 90ed2d4..cbcdc8a 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -3529,7 +3529,8 @@ class SettingsTests(unittest.TestCase): # Keys _apply_settings persists; every test that triggers a real or # fake config write restores these afterwards. _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "CHUNK_SIZE", "SMART_CHUNKING", "INPUT_DIR", + "OUTPUT_DIR", "CLONE_WAV_DIR", "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", @@ -3604,6 +3605,7 @@ class SettingsTests(unittest.TestCase): original_folders[1]) values = {"audio_format": "ogg", "audio_bitrate": " 192k ", "language": "en", "chunk_size": "300", + "smart_chunking": True, "input_dir": " /books ", "output_dir": "/audiobooks", "clone_wav_dir": " /refs/wavs ", "speed": "1.5", "debug": True, @@ -3626,6 +3628,7 @@ class SettingsTests(unittest.TestCase): "AUDIO_BITRATE": "192k", "LANGUAGE": "English", "CHUNK_SIZE": 300, + "SMART_CHUNKING": True, "INPUT_DIR": "/books", "OUTPUT_DIR": "/audiobooks", "CLONE_WAV_DIR": "/refs/wavs", @@ -3652,6 +3655,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(hub.config.AUDIO_BITRATE, "192k") self.assertEqual(hub.config.LANGUAGE, "English") self.assertEqual(hub.config.CHUNK_SIZE, 300) + self.assertEqual(hub.config.SMART_CHUNKING, True) self.assertEqual(hub.config.INPUT_DIR, "/books") self.assertEqual(hub.config.OUTPUT_DIR, "/audiobooks") self.assertEqual(hub.config.CLONE_WAV_DIR, "/refs/wavs") @@ -3671,6 +3675,7 @@ class SettingsTests(unittest.TestCase): self._snapshot_settings() base = {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", + "smart_chunking": True, "input_dir": "./input", "output_dir": "./output", "clone_wav_dir": "./voices", "speed": "1.0", "debug": False, @@ -3701,9 +3706,46 @@ class SettingsTests(unittest.TestCase): "audiocpp_remote_url": "not a url"}) mk_update.assert_not_called() + def test_smart_chunking_toggle_persists(self): + self._snapshot_settings() + + def fake_update(key, value, config_path=None): + setattr(hub.config, key, value) + return True + + values = {"audio_format": "m4b", "audio_bitrate": "128k", + "language": "English", "chunk_size": "250", + "smart_chunking": False, + "input_dir": "./input", "output_dir": "./output", + "clone_wav_dir": "./voices", + "speed": "1.0", "debug": False, + "stop_and_exit": True, "unload_models": True, + "qwen_port": "7860", + "faster_port": "8000", "audiocpp_port": "8080", + "sglomni_port": "8100"} + with patch.object(hub.common, "update_config_value", fake_update), \ + patch.object(hub, "_sync_audiocpp_server_port"): + hub._apply_settings(values) + self.assertEqual(hub.config.SMART_CHUNKING, False) + + values["smart_chunking"] = True + with patch.object(hub.common, "update_config_value", fake_update), \ + patch.object(hub, "_sync_audiocpp_server_port"): + hub._apply_settings(values) + self.assertEqual(hub.config.SMART_CHUNKING, True) + + def test_smart_chunking_field_defaults_on_with_help(self): + fields = hub._settings_fields() + field = next(f for f in fields if f["key"] == "smart_chunking") + self.assertEqual(field["kind"], "bool") + self.assertEqual(field["value"], hub.config.SMART_CHUNKING) + self.assertTrue(field["help"]) + self.assertTrue(field["label"], "Smart chunking") + def test_field_validators(self): self.assertIsNone(hub._validate_bitrate("128k")) self.assertIsNotNone(hub._validate_bitrate(" ")) + self.assertIsNotNone(hub._validate_bitrate(" ")) self.assertIsNone(hub._validate_language("English")) self.assertIsNone(hub._validate_language("en")) self.assertIsNotNone(hub._validate_language("Klingon")) @@ -3794,6 +3836,7 @@ class SettingsTests(unittest.TestCase): captured["fields"] = fields return {"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", + "smart_chunking": True, "input_dir": "/books", "output_dir": "/audiobooks", "clone_wav_dir": "/refs/wavs", "speed": "1.0", "debug": False, @@ -3817,7 +3860,8 @@ class SettingsTests(unittest.TestCase): hub._Hub(None).screen_settings() self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", - "chunk_size", "input_dir", "output_dir", + "chunk_size", "smart_chunking", "input_dir", + "output_dir", "clone_wav_dir", "speed", "debug", "stop_and_exit", "unload_models", @@ -3867,6 +3911,7 @@ class SettingsTests(unittest.TestCase): "audio_bitrate": "192k", "language": "English", "chunk_size": "300", + "smart_chunking": True, "input_dir": "/books", "output_dir": "/audiobooks", "clone_wav_dir": "/refs/wavs", @@ -4020,7 +4065,8 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "CHUNK_SIZE", "SMART_CHUNKING", "INPUT_DIR", + "OUTPUT_DIR", "CLONE_WAV_DIR", "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", @@ -4042,6 +4088,7 @@ class SettingsTests(unittest.TestCase): 'LANGUAGE = "English"\n' "\n" "CHUNK_SIZE = 250\n" + "SMART_CHUNKING = True\n" 'INPUT_DIR = "./input"\n' 'OUTPUT_DIR = "./output"\n' 'CLONE_WAV_DIR = "./voices"\n' |
