File size: 11,350 Bytes
f458e33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac7a28c
9d1e3f2
979f3c3
f458e33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
f458e33
 
 
 
 
 
 
 
 
ac7a28c
f458e33
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Tests Sprint 52 β€” mΓ©triques de lisibilitΓ© (Flesch).

Couvre :

1. ``count_syllables_word`` : heuristique sur des cas variΓ©s
   (mots vides, sans voyelle, avec diacritiques, multi-syllabe).
2. ``count_words`` / ``count_sentences`` : tokenisation simple,
   gestion des cas sans ponctuation finale.
3. ``flesch_score`` :
   - texte vide β†’ 0
   - score bornΓ© dans [0, 100]
   - cohΓ©rence : phrase simple > phrase complexe
   - diffΓ©rence FR vs EN (coefficients distincts)
4. ``flesch_delta`` :
   - GT = OCR β†’ 0
   - OCR modernisΓ© (LLM) β†’ delta positif
   - OCR dégradé (caractères cassés) → delta négatif
5. **Cas d'usage rΓ©aliste** : un GT historique long et complexe vs
   un OCR/LLM simplifiΓ© β†’ delta clairement positif (>15 pts).
6. Garde-fous : langue invalide, textes ne contenant que de la
   ponctuation.
7. Enregistrement dans le registre typΓ© Sprint 34 β€” la jonction
   ``(TEXT, TEXT)`` retourne bien ``flesch_delta_fr`` et
   ``flesch_delta_en``.
"""

from __future__ import annotations

import pytest

from picarones.evaluation.metric_registry import select_metrics
from picarones.domain.artifacts import ArtifactType
from picarones.measurements.readability import (
    count_sentences,
    count_syllables,
    count_syllables_word,
    count_words,
    flesch_delta,
    flesch_score,
)


# ──────────────────────────────────────────────────────────────────────────
# 1. Compteur de syllabes
# ──────────────────────────────────────────────────────────────────────────


class TestSyllableCounting:
    def test_empty_word_returns_zero(self) -> None:
        assert count_syllables_word("") == 0

    def test_word_without_vowel_returns_one(self) -> None:
        # Convention : un mot sans voyelle compte au moins 1 syllabe
        # (utile pour les acronymes type "BNF", "ALTO").
        assert count_syllables_word("BNF") == 1
        assert count_syllables_word("xyz") == 1

    def test_single_vowel(self) -> None:
        assert count_syllables_word("a") == 1
        assert count_syllables_word("Γ©") == 1

    def test_simple_words(self) -> None:
        # Heuristique groupes de voyelles consΓ©cutives
        assert count_syllables_word("chat") == 1     # 1 groupe : a
        assert count_syllables_word("chien") == 1    # 1 groupe : ie
        assert count_syllables_word("Γ©cole") == 3    # Γ©-o-e

    def test_diacritics_counted_as_vowels(self) -> None:
        # Les voyelles accentuΓ©es doivent Γͺtre reconnues
        s_with = count_syllables_word("Γ©tΓ©")
        s_without = count_syllables_word("ete")
        # MΓͺmes groupes de voyelles, mΓͺme nombre de syllabes
        assert s_with == s_without

    def test_count_syllables_sums_over_words(self) -> None:
        text = "le chat noir"
        assert count_syllables(text) == (
            count_syllables_word("le")
            + count_syllables_word("chat")
            + count_syllables_word("noir")
        )


# ──────────────────────────────────────────────────────────────────────────
# 2. Comptage mots / phrases
# ──────────────────────────────────────────────────────────────────────────


class TestTokenCounting:
    def test_empty_text(self) -> None:
        assert count_words("") == 0
        assert count_sentences("") == 0

    def test_simple_words(self) -> None:
        assert count_words("le chat noir") == 3

    def test_apostrophe_treated_as_word_char(self) -> None:
        # "l'amour" peut compter 1 ou 2 selon la convention. On
        # documente ici le comportement rΓ©el (1 token) pour fixer la
        # ref β€” peu important tant qu'on est cohΓ©rent.
        assert count_words("l'amour") == 1

    def test_sentence_split_basic(self) -> None:
        assert count_sentences("Premier. Deuxième. Troisième.") == 3

    def test_sentence_split_with_question_and_exclam(self) -> None:
        assert count_sentences("Allez ! Vraiment ? Oui.") == 3

    def test_no_final_punctuation_counts_as_one(self) -> None:
        # Un texte sans point final compte tout de mΓͺme comme 1 phrase
        # (Γ©vite division par zΓ©ro dans Flesch).
        assert count_sentences("texte sans point final") == 1


# ──────────────────────────────────────────────────────────────────────────
# 3. Score Flesch
# ──────────────────────────────────────────────────────────────────────────


class TestFleschScore:
    def test_empty_text_returns_zero(self) -> None:
        assert flesch_score("", lang="fr") == 0.0
        assert flesch_score("", lang="en") == 0.0

    def test_score_is_bounded(self) -> None:
        # Phrase très simple
        s = flesch_score("Le chat. Le chien.", lang="fr")
        assert 0.0 <= s <= 100.0
        # Phrase très complexe (mots longs, peu de phrases)
        s2 = flesch_score(
            "L'Γ©tablissement de l'historiographie mΓ©diΓ©vale "
            "contemporaine prΓ©sente d'importantes difficultΓ©s "
            "Γ©pistΓ©mologiques",
            lang="fr",
        )
        assert 0.0 <= s2 <= 100.0

    def test_simple_higher_than_complex(self) -> None:
        simple = "Le chat est noir. Le chien est blanc."
        complex_text = (
            "L'Γ©tablissement de l'historiographie mΓ©diΓ©vale "
            "contemporaine prΓ©sente d'importantes difficultΓ©s "
            "Γ©pistΓ©mologiques pour les chercheurs spΓ©cialisΓ©s."
        )
        assert flesch_score(simple, "fr") > flesch_score(complex_text, "fr")

    def test_fr_and_en_differ(self) -> None:
        # Sur un texte de complexitΓ© intermΓ©diaire (qui ne sature ni Γ 
        # 0 ni Γ  100), FR et EN donnent des scores diffΓ©rents β€”
        # coefficients distincts sur le ratio syllabes/mots
        # (73.6 FR vs 84.6 EN).
        text = (
            "Le chat noir traverse la rue. Le chien blanc dort sous "
            "l arbre. Les amis jouent ensemble dans le jardin pendant "
            "que le soleil brille au dessus de la colline."
        )
        s_fr = flesch_score(text, "fr")
        s_en = flesch_score(text, "en")
        # Les deux scores doivent Γͺtre dans la plage non saturΓ©e et
        # diffΓ©rer par les coefficients.
        assert 0.0 < s_fr < 100.0
        assert 0.0 < s_en < 100.0
        assert s_fr != s_en

    def test_invalid_lang_raises(self) -> None:
        with pytest.raises(ValueError, match="Langue"):
            flesch_score("test", lang="es")  # type: ignore[arg-type]

    def test_only_punctuation_returns_zero(self) -> None:
        assert flesch_score("...!!!???", lang="fr") == 0.0


# ──────────────────────────────────────────────────────────────────────────
# 4-5. Delta Flesch
# ──────────────────────────────────────────────────────────────────────────


class TestFleschDelta:
    def test_identical_texts_zero_delta(self) -> None:
        text = "Le chat est noir. Le chien est blanc."
        assert flesch_delta(text, text, "fr") == 0.0

    def test_empty_texts_zero_delta(self) -> None:
        assert flesch_delta("", "", "fr") == 0.0

    def test_realistic_modernization_yields_positive_delta(self) -> None:
        """Cas d'usage clΓ© : LLM modernise un texte historique β†’
        signal positif clair pour le dΓ©tecteur d'over-normalisation."""
        gt_old = (
            "Je vous envoie cette missive afin de vous informer "
            "de la situation Γ  la cour, oΓΉ plusieurs nouvelles "
            "mΓ©ritent votre attention."
        )
        ocr_modern = (
            "Je vous Γ©cris cette lettre pour vous parler de la "
            "situation Γ  la cour. Plusieurs nouvelles sont importantes."
        )
        delta = flesch_delta(gt_old, ocr_modern, "fr")
        # Le LLM modernisant doit produire un delta nettement positif
        # (phrases plus courtes + mots plus simples).
        assert delta > 10.0, f"Delta attendu > 10 pts, obtenu {delta:.1f}"

    def test_degraded_ocr_yields_negative_or_zero_delta(self) -> None:
        """OCR dΓ©gradΓ© : insertions/suppressions cassent les phrases β†’
        delta nul ou nΓ©gatif (lisibilitΓ© chute)."""
        gt = "Le chat est noir. Le chien est blanc. Les amis jouent."
        ocr_garbled = "L3 ch4t 35t n0ir. L3 ch13n 35t bl4nc. L35 4mi5 jou3nt."
        # Comportement variable selon la dΓ©gradation, mais on vΓ©rifie
        # au moins que l'Γ©cart est bornΓ©.
        delta = flesch_delta(gt, ocr_garbled, "fr")
        assert -100.0 <= delta <= 100.0

    def test_delta_is_bounded(self) -> None:
        # Cas extrΓͺmes : score chute Γ  0 vs score Γ  100
        d1 = flesch_delta("a b c.", "x" * 200, "fr")
        d2 = flesch_delta("x" * 200, "a b c.", "fr")
        assert -100.0 <= d1 <= 100.0
        assert -100.0 <= d2 <= 100.0


# ──────────────────────────────────────────────────────────────────────────
# 6. IntΓ©gration registre typΓ© (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────


class TestRegistryIntegration:
    def test_flesch_metrics_registered_for_text_text(self) -> None:
        # Force l'import qui peuple le registre
        import picarones.measurements.readability  # noqa: F401

        selected = select_metrics(
            (ArtifactType.TEXT, ArtifactType.TEXT),
        )
        names = {spec.name for spec in selected}
        assert "flesch_delta_fr" in names
        assert "flesch_delta_en" in names

    def test_registered_function_returns_same_as_direct_call(self) -> None:
        from picarones.evaluation.metric_registry import compute_at_junction

        gt = "Je vous envoie cette missive afin de vous informer."
        ocr = "Je vous Γ©cris une lettre. Voici la situation."
        out = compute_at_junction(
            gt, ocr, (ArtifactType.TEXT, ArtifactType.TEXT),
        )
        # Le delta enregistrΓ© FR doit matcher l'appel direct
        assert out["flesch_delta_fr"] == pytest.approx(
            flesch_delta(gt, ocr, "fr"), abs=1e-9,
        )