Picarones / tests /measurements /test_sprint29_detector_registry.py
Claude
test: réorganiser les 110 fichiers tests/test_*.py par cercle architectural
d109222 unverified
Raw
History Blame
11 kB
"""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é"):
@register_detector(FactType.GLOBAL_LEADER_CER, priority=999)
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] = []
@register_detector(
FactType.GLOBAL_LEADER_CER,
priority=15,
importance=FactImportance.MEDIUM,
)
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