Spaces:
Sleeping
Sleeping
Claude
test: réorganiser les 110 fichiers tests/test_*.py par cercle architectural
d109222 unverified | """Tests Sprint 29 — registre déclaratif des détecteurs narratifs. | |
| Sprint 29 remplace le pattern *« quatre fichiers à toucher pour ajouter | |
| un détecteur »* par un décorateur ``@register_detector`` qui : | |
| 1. enregistre la fonction dans un registre global trié par ``priority``, | |
| 2. refuse les doublons sur un même ``FactType``, | |
| 3. alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER`` et | |
| ``DETECTORS_BY_TYPE`` qui restent l'API publique historique. | |
| Garanties testées | |
| ----------------- | |
| - **Parité bit-à-bit** : la sortie de ``build_synthesis`` sur fixtures | |
| Sprint 19 est strictement identique à la version pré-Sprint 29. | |
| C'est le critère de sortie principal du sprint. | |
| - **Extensibilité** : décorer une fonction la rend automatiquement | |
| disponible via ``iter_detectors`` et ``DEFAULT_TYPE_ORDER``, sans | |
| toucher ni ``arbiter.py`` ni ``__init__.py``. | |
| - **Unicité** : tenter d'enregistrer deux détecteurs sur le même type | |
| lève ``ValueError``. | |
| - **Tri stable** : à priorités égales, l'ordre d'enregistrement est | |
| préservé. | |
| - **Cohérence interne** : tous les ``FactType`` du Sprint 4 sont | |
| enregistrés avec une priorité distincte. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from picarones.measurements.narrative import build_synthesis | |
| from picarones.core.facts import ( | |
| Fact, | |
| FactImportance, | |
| FactType, | |
| ) | |
| from picarones.measurements.narrative.registry import ( | |
| clear_registry, | |
| default_type_order, | |
| detector_for, | |
| iter_detectors, | |
| register_detector, | |
| unregister, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 1. Le registre par défaut contient les 12 détecteurs Sprint 4 | |
| # --------------------------------------------------------------------------- | |
| class TestRegistryPopulatedAtImport: | |
| def test_twelve_detectors_present(self): | |
| types = {entry.fact_type for entry in iter_detectors()} | |
| # Les 12 types canoniques du Sprint 4 + extensions Sprint 5 | |
| expected = set(FactType) | |
| assert types == expected, ( | |
| f"Types manquants : {expected - types} ; " | |
| f"types en trop : {types - expected}" | |
| ) | |
| def test_priorities_are_unique(self): | |
| priorities = [entry.priority for entry in iter_detectors()] | |
| assert len(priorities) == len(set(priorities)), ( | |
| "Deux détecteurs ne devraient pas avoir la même priorité par " | |
| "défaut — sinon l'ordre éditorial est indéterministe." | |
| ) | |
| def test_priorities_match_historical_order(self): | |
| """Les priorités définies au Sprint 29 doivent reproduire l'ordre | |
| canonique pré-Sprint 29 pour ne pas casser la lecture du rapport.""" | |
| from picarones.measurements.narrative.arbiter import _FALLBACK_TYPE_ORDER | |
| live = default_type_order() | |
| # Ils doivent contenir les mêmes types dans le même ordre. | |
| assert live == _FALLBACK_TYPE_ORDER | |
| def test_each_detector_callable(self): | |
| for entry in iter_detectors(): | |
| assert callable(entry.fn), ( | |
| f"L'entrée pour {entry.fact_type.value} n'est pas appelable" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # 2. Parité bit-à-bit avec la version pré-Sprint 29 | |
| # --------------------------------------------------------------------------- | |
| class TestParityWithPreSprint29: | |
| """Le refactor doit être strictement transparent : sur une fixture | |
| donnée, ``build_synthesis`` produit exactement les mêmes phrases.""" | |
| def _data_with_full_signal(self) -> dict: | |
| """Données qui font sortir la majorité des détecteurs.""" | |
| return { | |
| "meta": {"document_count": 20, "corpus_name": "test"}, | |
| "ranking": [ | |
| {"engine": "A", "mean_cer": 0.05, "mean_wer": 0.10}, | |
| {"engine": "B", "mean_cer": 0.08, "mean_wer": 0.15}, | |
| {"engine": "C", "mean_cer": 0.20, "mean_wer": 0.30}, | |
| ], | |
| "engines": [ | |
| {"name": "A", "cer": 0.05, "n_docs": 20}, | |
| {"name": "B", "cer": 0.08, "n_docs": 20}, | |
| {"name": "C", "cer": 0.20, "n_docs": 20}, | |
| ], | |
| "statistics": { | |
| "pairwise_wilcoxon": [ | |
| {"engine_a": "A", "engine_b": "B", "p_value": 0.012, | |
| "significant": True, "n_pairs": 20}, | |
| ], | |
| "bootstrap_cis": [ | |
| {"engine": "A", "mean": 0.05, "ci_lower": 0.03, "ci_upper": 0.07}, | |
| {"engine": "B", "mean": 0.08, "ci_lower": 0.06, "ci_upper": 0.10}, | |
| {"engine": "C", "mean": 0.20, "ci_lower": 0.18, "ci_upper": 0.22}, | |
| ], | |
| }, | |
| } | |
| def test_synthesis_has_some_content(self): | |
| data = self._data_with_full_signal() | |
| result = build_synthesis(data, "fr") | |
| assert len(result["sentences"]) >= 1 | |
| def test_synthesis_is_deterministic_across_calls(self): | |
| data = self._data_with_full_signal() | |
| a = build_synthesis(data, "fr") | |
| b = build_synthesis(data, "fr") | |
| assert a == b | |
| def test_global_leader_is_first(self): | |
| # Le leader CER doit dominer la synthèse — vérifie que le | |
| # registre conserve la priorité 10 sur GLOBAL_LEADER_CER. | |
| data = self._data_with_full_signal() | |
| result = build_synthesis(data, "fr") | |
| # La première phrase doit citer A (CER 0.05) | |
| assert "A" in result["sentences"][0] | |
| # --------------------------------------------------------------------------- | |
| # 3. Extensibilité : décorer une fonction tierce | |
| # --------------------------------------------------------------------------- | |
| class TestThirdPartyExtension: | |
| """Vérifie qu'on peut ajouter un détecteur depuis un module tiers | |
| sans toucher aux fichiers du package — preuve de l'autonomie du | |
| décorateur. Utilise un type FactType existant non utilisé pour | |
| éviter de polluer le registre permanent.""" | |
| def setup_method(self): | |
| # Si jamais un précédent test a laissé un faux détecteur, on | |
| # nettoie. On ne touche PAS aux 12 builtins. | |
| for fake_type in (FactType.GLOBAL_LEADER_CER,): | |
| entry = detector_for(fake_type) | |
| if entry is not None and entry.fn.__module__ == __name__: | |
| unregister(fake_type) | |
| def teardown_method(self): | |
| # Idem | |
| for fake_type in (FactType.GLOBAL_LEADER_CER,): | |
| entry = detector_for(fake_type) | |
| if entry is not None and entry.fn.__module__ == __name__: | |
| unregister(fake_type) | |
| def test_decorator_rejects_double_registration(self): | |
| # Tenter de réenregistrer GLOBAL_LEADER_CER doit lever. | |
| with pytest.raises(ValueError, match="déjà enregistré"): | |
| def _double(data): | |
| return [] | |
| def test_unregister_then_replace_works(self): | |
| # On peut explicitement retirer puis remplacer. | |
| original = detector_for(FactType.GLOBAL_LEADER_CER) | |
| assert original is not None | |
| try: | |
| unregister(FactType.GLOBAL_LEADER_CER) | |
| calls: list[dict] = [] | |
| def _replacement(data: dict): | |
| calls.append(data) | |
| return [] | |
| entry = detector_for(FactType.GLOBAL_LEADER_CER) | |
| assert entry.priority == 15 | |
| assert entry.importance == FactImportance.MEDIUM | |
| entry.fn({"meta": {}}) | |
| assert len(calls) == 1 | |
| finally: | |
| unregister(FactType.GLOBAL_LEADER_CER) | |
| # Restaure l'original | |
| register_detector( | |
| original.fact_type, | |
| priority=original.priority, | |
| importance=original.importance, | |
| )(original.fn) | |
| # --------------------------------------------------------------------------- | |
| # 4. iter_detectors trie par priority et reste stable | |
| # --------------------------------------------------------------------------- | |
| class TestIterDetectorsSorted: | |
| def test_returns_sorted_by_priority(self): | |
| priorities = [e.priority for e in iter_detectors()] | |
| assert priorities == sorted(priorities) | |
| def test_first_detector_is_highest_priority(self): | |
| first = iter_detectors()[0] | |
| assert first.fact_type == FactType.GLOBAL_LEADER_CER | |
| assert first.priority == 10 | |
| # --------------------------------------------------------------------------- | |
| # 5. Robustesse — registre vide | |
| # --------------------------------------------------------------------------- | |
| class TestEmptyRegistryFallback: | |
| """Si le registre est vidé (cas extrême — chargement partiel par | |
| les tests), ``select_facts`` doit utiliser ``_FALLBACK_TYPE_ORDER`` | |
| et ne pas planter.""" | |
| def test_select_facts_works_on_empty_registry(self): | |
| from picarones.measurements.narrative.arbiter import select_facts | |
| # Sauvegarder l'état complet pour le restaurer | |
| backup = list(iter_detectors()) | |
| try: | |
| clear_registry() | |
| facts = [ | |
| Fact( | |
| type=FactType.GLOBAL_LEADER_CER, | |
| importance=FactImportance.HIGH, | |
| payload={"engine": "A"}, | |
| engines_involved=("A",), | |
| ), | |
| ] | |
| selected = select_facts(facts, max_facts=3) | |
| assert len(selected) == 1 | |
| finally: | |
| # Restaure le registre | |
| for entry in backup: | |
| register_detector( | |
| entry.fact_type, | |
| priority=entry.priority, | |
| importance=entry.importance, | |
| )(entry.fn) | |
| # --------------------------------------------------------------------------- | |
| # 6. DETECTORS_BY_TYPE reste cohérent avec le registre | |
| # --------------------------------------------------------------------------- | |
| class TestLegacyAliasStillWorks: | |
| def test_detectors_by_type_matches_registry(self): | |
| from picarones.measurements.narrative.detectors import DETECTORS_BY_TYPE | |
| registry_types = {e.fact_type for e in iter_detectors()} | |
| legacy_types = set(DETECTORS_BY_TYPE) | |
| # Les deux ensembles peuvent diverger si DETECTORS_BY_TYPE est | |
| # capturé à l'import et que des types sont enregistrés après ; | |
| # mais à la création de l'objet ``DETECTORS_BY_TYPE`` lui-même | |
| # (au chargement de detectors.py), tous les builtins sont là. | |
| assert legacy_types <= registry_types | |
| for k, v in DETECTORS_BY_TYPE.items(): | |
| entry = detector_for(k) | |
| assert entry is not None | |
| assert entry.fn is v | |