from __future__ import annotations import pytest from unittest.mock import patch from abogen.kokoro_text_normalization import ( DEFAULT_APOSTROPHE_CONFIG, normalize_for_pipeline, normalize_roman_numeral_titles, ) from abogen.normalization_settings import ( apply_overrides as apply_normalization_overrides, build_apostrophe_config, get_runtime_settings, ) from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions SPACY_RESOLVER_AVAILABLE = bool(resolve_ambiguous_contractions("It's been a long time.")) def _normalize_text(text: str, *, normalization_overrides: dict[str, object] | None = None) -> str: runtime_settings = get_runtime_settings() if normalization_overrides: runtime_settings = apply_normalization_overrides(runtime_settings, normalization_overrides) config = build_apostrophe_config(settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG) return normalize_for_pipeline(text, config=config, settings=runtime_settings) def test_title_abbreviations_are_expanded(): text = "Dr. Watson met Mr. Holmes and Ms. Hudson." normalized = _normalize_text(text) assert "Doctor" in normalized assert "Mister" in normalized assert "Miz" in normalized def test_suffix_abbreviations_are_expanded_with_case_preserved(): text = "John Doe Jr. spoke to JANE DOE SR. about the estate." normalized = _normalize_text(text) assert "John Doe Junior" in normalized assert "JANE DOE SENIOR" in normalized def test_missing_terminal_punctuation_is_added(): normalized = _normalize_text("Chapter 1") assert normalized.endswith(".") def test_terminal_punctuation_respects_closing_quotes(): normalized = _normalize_text('"Chapter 1"') compact = normalized.replace(" ", "") assert compact.endswith('."') def test_normalization_preserves_spacing_around_quotes_and_hyphen(): sample = "“Still,” said Château-Renaud, “Dr. d’Avrigny, who attends my mother, declares he is in despair about it." normalized = _normalize_text(sample) assert normalized.startswith( "“Still,” said Château-Renaud, “Doctor d'Avrigny, who attends my mother, declares he is in despair about it." ) assert " " not in normalized assert "Château-Renaud" in normalized assert "Doctor d'Avrigny" in normalized def test_normalize_roman_titles_converts_when_majority() -> None: titles = ["I: Opening", "II: Rising Action", "III: Climax"] normalized = normalize_roman_numeral_titles(titles) assert normalized == ["1: Opening", "2: Rising Action", "3: Climax"] def test_normalize_roman_titles_skips_when_not_majority() -> None: titles = ["Preface", "I: Opening", "Acknowledgements"] normalized = normalize_roman_numeral_titles(titles) assert normalized == titles def test_normalize_roman_titles_preserves_separators() -> None: titles = [" IV. The Trial", "V - The Verdict", "VI\nAftermath"] normalized = normalize_roman_numeral_titles(titles) assert normalized[0] == " 4. The Trial" assert normalized[1] == "5 - The Verdict" assert normalized[2].startswith("6\nAftermath") def test_grouped_numbers_are_spelled_out() -> None: normalized = _normalize_text("The vault holds 35,000 credits") assert "thirty-five thousand" in normalized.lower() def test_numeric_ranges_are_spoken_with_to() -> None: normalized = _normalize_text("Chapters 1-3") assert "one to three" in normalized.lower() def test_simple_fractions_are_spoken() -> None: normalized = _normalize_text("Add 1/2 cup of sugar") assert "one half" in normalized.lower() def test_plain_numbers_are_spelled_out() -> None: normalized = _normalize_text("He rolled a 42.") assert "forty-two" in normalized.lower() def test_decimal_numbers_include_point() -> None: normalized = _normalize_text("Book 4.5 of the series.") assert "four point five" in normalized.lower() def test_space_separated_numbers_become_ranges() -> None: normalized = _normalize_text("Read pages 12 14 tonight.") assert "pages twelve to fourteen" in normalized.lower() def test_year_like_numbers_use_common_pronunciation() -> None: normalized = _normalize_text("In 1924 the journey began") folded = normalized.lower().replace("-", " ") assert "nineteen twenty four" in folded def test_early_century_years_use_hundred_format() -> None: normalized = _normalize_text("In 1204 the city fell") assert "twelve hundred" in normalized.lower() assert "oh four" in normalized.lower() def test_roman_numerals_in_titles_are_converted() -> None: normalized = _normalize_text("Chapter IV begins now") assert "chapter four" in normalized.lower() def test_roman_numeral_suffixes_use_ordinals() -> None: normalized = _normalize_text("Bob Smith II arrived late") assert "bob smith the second" in normalized.lower() def test_lowercase_roman_after_part_converts_to_cardinal() -> None: normalized = _normalize_text("We studied part iii of the manuscript.") assert "part three" in normalized.lower() def test_hyphenated_phase_with_roman_is_converted() -> None: normalized = _normalize_text("They executed phase-IV without delay.") assert "phase four" in normalized.lower() def test_all_caps_quotes_are_sentence_cased() -> None: normalized = _normalize_text('"THIS IS A TEST."') cleaned = normalized.replace('" ', '"') assert '"This is a test."' in cleaned def test_caps_quote_preserves_acronyms() -> None: normalized = _normalize_text("“THE NASA TEAM ARRIVED.”") assert "“The NASA team arrived.”" in normalized def test_caps_quote_normalization_respects_override() -> None: normalized = _normalize_text( '"KEEP SHOUTING."', normalization_overrides={"normalization_caps_quotes": False}, ) cleaned = normalized.replace('" ', '"') assert '"KEEP SHOUTING."' in cleaned def test_recent_years_split_twenty_style() -> None: normalized = _normalize_text("In 2025 we planned ahead") folded = normalized.lower().replace("-", " ") assert "twenty twenty five" in folded def test_two_thousands_use_two_thousand_prefix() -> None: normalized = _normalize_text("In 2005 we celebrated") assert "two thousand five" in normalized.lower() def test_year_style_can_be_disabled() -> None: normalized = _normalize_text( "In 2025 we planned ahead", normalization_overrides={"normalization_numbers_year_style": "off"}, ) folded = normalized.lower().replace("-", " ") assert "twenty twenty five" not in folded def test_contractions_can_be_kept_when_override_disabled() -> None: normalized = _normalize_text( "It's a good day.", normalization_overrides={"normalization_apostrophes_contractions": False}, ) assert "It's" in normalized def test_sibilant_possessives_remain_when_marking_disabled() -> None: normalized = _normalize_text( "The boss's chair wobbled.", normalization_overrides={"normalization_apostrophes_sibilant_possessives": False}, ) assert "boss's" in normalized assert "boss iz" not in normalized.lower() def test_decades_can_skip_expansion_when_disabled() -> None: normalized = _normalize_text( "Classic hits from the '90s filled the hall.", normalization_overrides={"normalization_apostrophes_decades": False}, ) assert "'90s" in normalized @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_spacy_disambiguates_it_has_from_context() -> None: normalized = _normalize_text("It's been a long time.") assert "It has been a long time." == normalized @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_spacy_disambiguates_it_is_from_context() -> None: normalized = _normalize_text("It's cold outside.") assert "It is cold outside." == normalized @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_spacy_disambiguates_she_had() -> None: normalized = _normalize_text("She'd left before dawn.") assert "She had left before dawn." == normalized @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_spacy_disambiguates_she_would() -> None: normalized = _normalize_text("She'd go if invited.") assert "She would go if invited." == normalized @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_sample_sentence_handles_complex_contractions() -> None: sample = "I've heard the captain'll arrive by dusk, but they'd said the same yesterday." normalized = _normalize_text(sample) assert ( "I have heard the captain will arrive by dusk, but they had said the same yesterday." == normalized ) def test_modal_will_contractions_can_be_disabled() -> None: sample = "The captain'll arrive at dawn." normalized = _normalize_text( sample, normalization_overrides={"normalization_contraction_modal_will": False}, ) assert "captain'll" in normalized @pytest.fixture(autouse=True) def mock_settings(): defaults = { "normalization_numbers": True, "normalization_titles": True, "normalization_terminal": True, "normalization_phoneme_hints": True, "normalization_caps_quotes": True, "normalization_apostrophes_contractions": True, "normalization_apostrophes_plural_possessives": True, "normalization_apostrophes_sibilant_possessives": True, "normalization_apostrophes_decades": True, "normalization_apostrophes_leading_elisions": True, "normalization_apostrophe_mode": "spacy", "normalization_contraction_aux_be": True, "normalization_contraction_aux_have": True, "normalization_contraction_modal_will": True, "normalization_contraction_modal_would": True, "normalization_contraction_negation_not": True, "normalization_contraction_let_us": True, "normalization_currency": True, "normalization_footnotes": True, "normalization_numbers_year_style": "american", } with patch("tests.test_text_normalization.get_runtime_settings", return_value=defaults): yield