Files

376 lines
16 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Acceptance checks for the Polish number normalizer.
The model produces valid case variants, so these assert on *properties* — a
digit is gone, a word stem is present — never on an exact string, except where
the behaviour genuinely is exact (model-not-called, pass-through).
Run them in the normalizer venv:
normalizer/venv/bin/python -m pytest normalizer/tests -v
They pass with networking disabled. Tests that need the neural path are skipped
automatically when the model has not been downloaded; the rest exercise the
fallback and the protect/decide/restore logic, which is where the integration
bugs actually live.
"""
import os
import re
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
os.environ.setdefault("HF_HUB_OFFLINE", "1")
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
import normalizer # noqa: E402
HAVE_MODEL = normalizer.model_available()
needs_model = unittest.skipUnless(
HAVE_MODEL, "model not downloaded — see normalizer/README.md")
DIGITS = re.compile(r'\d')
class Selection(unittest.TestCase):
"""Which blocks are worth sending at all. lektor_ai_comfy.py mirrors this
rule to keep blocks that need nothing from crossing the process boundary."""
def test_single_digit_is_not_worth_normalizing(self):
self.assertFalse(normalizer.needs_normalization("Mam 3 koty."))
def test_no_digits_at_all(self):
self.assertFalse(normalizer.needs_normalization("Zwykłe zdanie."))
def test_multi_digit(self):
self.assertTrue(normalizer.needs_normalization("Wygrał 1247 złotych."))
def test_complex_expressions_are_not_single_digits(self):
for text in ("Spotkanie o 9:30.", "Około 3,5 metra.", "Mieszka pod 12/45.",
"O 14:30 w środę."):
with self.subTest(text=text):
self.assertTrue(normalizer.needs_normalization(text))
def test_thousands_with_spaces(self):
self.assertTrue(normalizer.needs_normalization("populacja 1 350 000 osób"))
def test_single_digit_before_punctuation_is_still_single(self):
for text in ("Mam 3, potem 4.", "Zostało 5.", "Tylko 7!"):
with self.subTest(text=text):
self.assertFalse(normalizer.needs_normalization(text))
def test_multi_digit_before_punctuation_is_caught(self):
self.assertTrue(normalizer.needs_normalization("Przód 85, tył 75."))
class ClientRuleInSync(unittest.TestCase):
"""lektor_ai_comfy.py keeps its own copy of the selection rule so blocks
that need nothing never start a subprocess. A copy that drifts sends the
wrong blocks — or silently stops sending any — so it is checked here, where
the authoritative rule lives. The caller is pure standard library, so this
venv can import it."""
SAMPLES = [
"Mam 3 koty.", "Zwykłe zdanie.", "Wygrał 1247 złotych.",
"Przód 85, tył 75.", "populacja 1 350 000 osób", "Radio 95,7 FM.",
"Spotkanie o 14:30.", "Mieszka pod 12/45.", "Około 3,5 metra.",
"Formuła 1", "Masz F2, masz F3.", "Zostało 5.", "Mam 3, potem 4.",
"W 2026 roku.", "Tylko 7!", "1 pies, 2 koty, 300 myszy.",
]
def test_same_verdict_on_every_sample(self):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
try:
import lektor_ai_comfy
except ImportError as exc: # pragma: no cover - only if moved
self.skipTest(f"caller not importable: {exc}")
for text in self.SAMPLES:
with self.subTest(text=text):
self.assertEqual(lektor_ai_comfy.needs_number_normalization(text),
normalizer.needs_normalization(text))
class Boundaries(unittest.TestCase):
"""KEEP_RE no longer shields anything — it only decides whether a block is
worth sending. These pin down where a number starts and ends."""
def test_grouped_thousands_are_not_split(self):
"""The leading 1 of "1 350 000" is not a standalone single digit; if it
counted as one the block might not be sent at all."""
protected = normalizer.KEEP_RE.sub("X", "populacja 1 350 000 osób")
self.assertIn("1 350 000", protected)
def test_complex_numbers_are_not_single_digits(self):
protected = normalizer.KEEP_RE.sub("X", "Mam 3 koty, 25 psów i 3,5 kg.")
self.assertNotIn(" 3 ", protected) # standalone single digit
self.assertIn("25", protected) # multi-digit untouched
self.assertIn("3,5", protected) # decimal untouched
class Routing(unittest.TestCase):
"""Which leg a block goes to. The two models fail in opposite directions,
so a misrouted block gets the worse of the two."""
PLAIN = ["Wygrał 1247 złotych.", "Mówili o 250000 uchodźcach.",
"Przód 85, tył 75.", "Małpi gaj za 150 milionów dolarów.",
"Bardzo. Ma z 80 lat.", "populacja 1 350 000 osób",
# A range written in words is two plain integers; only a dash
# between digits makes it a compound the fine-tune reads better.
"Ma od 15 do 20 lat."]
COMPLEX = ["Spotkanie o 14:30.", "Radio 95,7 FM.", "Mieszka pod 12/45.",
"Teraz na P12.", "Lot LO 3842 jest opóźniony.",
"Gdzie 13 maja 1950 roku się zaczęło.", "Ma od 10-20 lat.",
"Silnik ma 1998 cm3 pojemności.", "W 2026 roku."]
def test_plain(self):
for text in self.PLAIN:
with self.subTest(text=text):
self.assertEqual(normalizer.route(text), "plain")
def test_complex(self):
for text in self.COMPLEX:
with self.subTest(text=text):
self.assertEqual(normalizer.route(text), "complex")
def test_case_sensitivity_of_the_call_sign_rule(self):
"""Applied case-insensitively this rule matched any short word before a
number and misrouted a third of the sample."""
self.assertEqual(normalizer.route("Lot LO 3842 wystartował."), "complex")
self.assertEqual(normalizer.route("Zapłacił za 150 złotych."), "plain")
class Numerals(unittest.TestCase):
"""The value guard needs numeral identity across inflection, which no
consonant rule provides: dwieście/dwustu are one number, trzy/trzysta are
not."""
SAME = [("dwieście", "dwustu"), ("pięćdziesiąt", "pięćdziesięciu"),
("trzydzieści", "trzydziestu"), ("czterdzieści", "czterdziestu"),
("tysiąc", "tysiącach"), ("milion", "milionach"),
("osiem", "ośmiu"), ("pięć", "piątym"), ("dwadzieścia", "dwudziesty")]
DIFFERENT = [("trzy", "trzysta"), ("pięć", "pięćdziesiąt"),
("dwa", "dwieście"), ("milion", "miliard"),
("sto", "sześćset"), ("cztery", "czternaście")]
def test_same_number(self):
for a, b in self.SAME:
with self.subTest(pair=(a, b)):
self.assertEqual(normalizer._numeral(a), normalizer._numeral(b))
self.assertTrue(normalizer._numeral(a))
def test_different_numbers(self):
for a, b in self.DIFFERENT:
with self.subTest(pair=(a, b)):
self.assertNotEqual(normalizer._numeral(a), normalizer._numeral(b))
def test_ordinary_words_are_not_numerals(self):
for word in ("koty", "uchodźcach", "okrążenie", "prędkość"):
self.assertEqual(normalizer._numeral(word), "")
class Guards(unittest.TestCase):
"""Every check here fired on a real failure during development. The
false-positive cases matter just as much: a guard that rejects good output
silently downgrades the run."""
def test_accepts_correct_output(self):
for original, candidate in [
("Mówili o 250000 uchodźcach.",
"Mówili o dwustu pięćdziesięciu tysiącach uchodźców."),
("To wypis sprzed 30 lat.", "To wypis sprzed trzydziestu lat."),
("kolizja na 55 okrążeniu", "kolizja na pięćdziesiątym piątym okrążeniu"),
("misja to Apollo 25", "misja to Apollo dwudziesty piąty"),
]:
with self.subTest(candidate=candidate):
self.assertEqual(normalizer._guard(original, candidate, True), "")
def test_value_changed(self):
reason = normalizer._guard("Z 8250 osobami.",
"Z osiem tysięcy dwustu pięciu osobami.", True)
self.assertIn("numbers changed", reason)
def test_added_numeral(self):
"""The check must run both ways. "dwadzieścia" -> "dwadzieścia pięć"
keeps every expected word and still turns 20 into 25 — a one-directional
subset check passed this and shipped 25 years of service as 20."""
reason = normalizer._guard("Nagroda Za 20 Lat Pracy.",
"Nagroda Za dwadzieścia Pięć Lat Pracy.", True)
self.assertIn("added", reason)
def test_single_digits_count_toward_the_expectation(self):
"""num2words skips one-digit numbers but the model expands them, so the
expectation must cover them or "trzy" looks like an invention."""
self.assertEqual(normalizer._guard("Mam 3 koty i 25 psów.",
"Mam trzy koty i dwadzieścia pięć psów.",
True), "")
def test_word_lost(self):
reason = normalizer._guard("Populacja to 38000000 mieszkańców.",
"Populacja to trzydzieści osiem milionów.", True)
self.assertIn("word lost", reason)
def test_minus_lost(self):
reason = normalizer._guard("Na dworze jest -12 stopni.",
"Na dworze jest dwanaście stopni.", True)
self.assertIn("minus", reason)
def test_repetition_loop(self):
reason = normalizer._guard("Zapłaciłem 1234,56 zł.",
"Zapłaciłem " + "łącznie " * 40 + "tysiąc.", False)
self.assertTrue(reason)
def test_invalid_characters(self):
self.assertIn("invalid", normalizer._guard("Dnia 15 września 1631 roku.",
"Dnia " + "�" * 30, False))
def test_digits_left(self):
self.assertIn("digits", normalizer._guard("Ma 25 lat.", "Ma 25 lat.", False))
def test_value_check_is_skipped_for_identifiers(self):
""""LO 3842" is correctly read digit by digit, so demanding num2words'
rendering of it would reject a good answer."""
self.assertEqual(
normalizer._guard("Lot LO 3842 jest opóźniony.",
"Lot LO trzy osiem cztery dwa jest opóźniony.",
False), "")
class Fallback(unittest.TestCase):
"""Degraded path: nominative only, bare integers only. It must still never
lose a number or raise."""
def test_expands_multi_digit(self):
out = normalizer._fallback("Wygrał 1247 złotych.")
self.assertNotIn("1247", out)
self.assertIn("tysiąc", out)
def test_keeps_single_digit(self):
self.assertEqual(normalizer._fallback("Mam 3 koty."), "Mam 3 koty.")
def test_large_number_with_separators(self):
out = normalizer._fallback("populacja 1 350 000 osób")
self.assertNotIn("350", out)
self.assertIn("milion", out)
def test_leaves_decimals_and_times_alone(self):
for text in ("Około 3,5 metra.", "Spotkanie o 9:30.", "Radio 95,7 FM."):
self.assertEqual(normalizer._fallback(text), text)
def test_number_followed_by_sentence_punctuation(self):
"""A comma or full stop ends a number; it does not disqualify it. Real
subtitles are full of "Przód 85, tył 75." and the brief's boundary set
skipped every one of them."""
out = normalizer._fallback("Przód 85, tył 75.")
self.assertNotIn("85", out)
self.assertNotIn("75", out)
self.assertIn("osiemdziesiąt pięć", out)
self.assertIn("siedemdziesiąt pięć", out)
def test_never_raises(self):
for text in ("", "———", "9" * 400, "1/0", "0000"):
with self.subTest(text=text[:20]):
normalizer._fallback(text)
class NormalizeContract(unittest.TestCase):
"""Properties that hold on both paths — this is what the caller relies on."""
def test_no_digits_returns_input_unchanged(self):
text = "Zwykłe zdanie bez liczb."
self.assertEqual(normalizer.normalize(text), text)
def test_only_single_digits_skips_the_model(self):
calls = []
original = normalizer._model_normalize
normalizer._model_normalize = lambda t: calls.append(t) or t
try:
self.assertEqual(normalizer.normalize("Mam 3 koty."), "Mam 3 koty.")
finally:
normalizer._model_normalize = original
self.assertEqual(calls, [], "model must not be called for single digits")
def test_block_of_only_single_digits_is_returned_untouched(self):
"""The cheap half of the brief's idea, kept: nothing to gain from
sending it, a whole sentence of rewriting to risk."""
for text in ("Mam 3 koty.", "Zostało 5 minut.", "Tylko 7!"):
with self.subTest(text=text):
self.assertEqual(normalizer.normalize(text), text)
def test_empty_and_odd_inputs(self):
self.assertEqual(normalizer.normalize(""), "")
self.assertEqual(normalizer.normalize(" "), " ")
def test_generation_error_falls_back(self):
original = normalizer._model_normalize
def boom(_):
raise RuntimeError("CUDA out of memory")
normalizer._model_normalize = boom
try:
out = normalizer.normalize("Wygrał 1247 złotych.")
finally:
normalizer._model_normalize = original
self.assertNotIn("1247", out)
self.assertIn("tysiąc", out)
@needs_model
class Neural(unittest.TestCase):
"""The acceptance list from the build brief, against the real model."""
def test_multi_digit_expanded(self):
out = normalizer.normalize("Wygrał 1247 złotych.")
self.assertNotIn("1247", out)
self.assertIn("tysiąc", out.lower())
def test_large_number(self):
out = normalizer.normalize("Miasto ma populację 1 350 000 osób.")
self.assertNotIn("350", out)
self.assertIn("milion", out.lower())
def test_mixed_single_and_multi(self):
"""A block sent for its multi-digit number gets its single digits
normalized too — that is the point of dropping the placeholders, and
it is what makes "3 maja" come out as "trzeciego maja"."""
out = normalizer.normalize("Mam 3 koty i 25 psów.").lower()
self.assertNotIn("3", out)
self.assertIn("trzy", out)
self.assertIn("dwadzieścia pięć", out)
def test_leg_is_reported(self):
self.assertEqual(normalizer.normalize_detail("Mam 3 koty.")[1], "skipped")
self.assertEqual(normalizer.normalize_detail("Jest dopiero 21.00.")[1],
"complex")
def test_ordinal_date_is_fixed(self):
"""The reason the placeholders had to go: left as a digit, the TTS
reads "3 maja" as "trzy maja"."""
out = normalizer.normalize("Urodził się 3 maja 1791 roku.").lower()
self.assertIn("trzeciego maja", out)
def test_no_digits_leak(self):
out = normalizer.normalize("Pędzi 300 kilometrów na godzinę.")
self.assertFalse(DIGITS.search(out))
def test_case_declension(self):
"""The whole reason for a neural normalizer: 'o 25 psach' is locative,
so the numeral must not come out nominative."""
out = normalizer.normalize("Mówimy o 25 psach.").lower()
self.assertNotIn("25", out)
self.assertNotIn("dwadzieścia pięć psach", out)
def test_time(self):
out = normalizer.normalize("Spotkanie o 14:30.")
self.assertFalse(DIGITS.search(out))
def test_offline(self):
self.assertEqual(os.environ.get("HF_HUB_OFFLINE"), "1")
self.assertFalse(DIGITS.search(normalizer.normalize("Ma 99 lat.")))
if __name__ == "__main__":
print(f"model available: {HAVE_MODEL}", file=sys.stderr)
unittest.main(verbosity=2)