Files
veye-lalwa/tests/test_collecteurs.py
T

300 lines
13 KiB
Python
Raw Permalink Normal View History

"""Collecteurs et rapprochement.
Les parseurs sont testés sur des fixtures HTML figées, capturées le 25 juillet
2026 sur les sites officiels : aucun test ne dépend du réseau. Une fixture qui
cesse de passer signale un changement de structure du site — c'est le but.
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from selectolax.parser import HTMLParser
from pipeline.collecteurs.conseil_constitutionnel import (
_index_des_colonnes,
_lire_decision_rendue,
_lire_ligne_instance,
)
from pipeline.collecteurs.http import ClientHttp, _cle_de_cache
from pipeline.collecteurs.legifrance import CollecteurLegifrance, _lire_titre
from pipeline.collecteurs.senat import CollecteurSenat
from pipeline.modeles import ResultatCC, Statut, TypeTexte
from pipeline.rapprochement import Issue, normaliser, rapprocher, similarite
FIXTURES = Path(__file__).parent / "fixtures"
# ─────────────────────────────────────────────────────────────────────────────
# Conseil constitutionnel
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def affaires_dc() -> list:
tableau = HTMLParser(
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
).css_first("table")
colonnes = _index_des_colonnes(tableau)
return [
affaire
for ligne in tableau.css("tbody tr")
if (affaire := _lire_ligne_instance(ligne, colonnes))
]
def test_les_sept_affaires_dc_en_instance(affaires_dc) -> None:
"""Le corpus en annonçait sept ; le registre officiel les confirme."""
numeros = {a.numero_affaire for a in affaires_dc}
assert numeros == {
"2026-907 DC",
"2026-910 DC",
"2026-911 DC",
"2026-912 DC",
"2026-913 DC",
"2026-914 DC",
"2026-915 DC",
}
def test_saisine_de_la_loi_riposte(affaires_dc) -> None:
riposte = next(a for a in affaires_dc if a.numero_affaire == "2026-915 DC")
assert riposte.date_saisine == date(2026, 7, 24)
assert riposte.resultat is ResultatCC.EN_INSTANCE
assert "soixante députés" in (riposte.saisissants or "")
def test_la_programmation_militaire_recoit_son_numero(affaires_dc) -> None:
"""Le rapport la citait sans numéro d'affaire ; la collecte le fournit."""
lpm = next(a for a in affaires_dc if a.numero_affaire == "2026-907 DC")
assert lpm.date_saisine == date(2026, 7, 6)
assert "programmation militaire" in (lpm.resume or "").lower()
def test_colonnes_lues_par_entete_et_non_par_position() -> None:
"""Les tableaux DC et QPC n'ont pas les mêmes colonnes."""
tableau = HTMLParser(
(FIXTURES / "cc_affaires_dc.html").read_text(encoding="utf-8")
).css_first("table")
colonnes = _index_des_colonnes(tableau)
assert colonnes["saisissants"] != colonnes["date_saisine"]
assert set(colonnes) >= {"affaire", "disposition", "saisissants", "date_saisine"}
@pytest.mark.parametrize(
("intitule", "attendu"),
[
("2026-908 DC du 23 juillet 2026 Loi organique … Conformité", ResultatCC.CONFORME),
(
"2026-906 DC du 23 juillet 2026 Loi … Conformité - réserve",
ResultatCC.CONFORME_AVEC_RESERVES,
),
(
"2026-909 DC du 23 juillet 2026 Loi … Non conformité partielle",
ResultatCC.NON_CONFORMITE_PARTIELLE,
),
],
)
def test_lecture_du_resultat_d_une_decision(intitule: str, attendu: ResultatCC) -> None:
"""« Conformité » ne contient pas « conforme » : l'ordre des motifs compte."""
decision = _lire_decision_rendue(f"Décision n° {intitule}", None)
assert decision is not None
assert decision.resultat is attendu
assert decision.date_decision == date(2026, 7, 23)
def test_decisions_rendues_de_la_fixture() -> None:
document = HTMLParser((FIXTURES / "cc_decisions.html").read_text(encoding="utf-8"))
decisions = [
d
for article in document.css("article")
if (d := _lire_decision_rendue(" ".join(article.text(separator=" ").split()), None))
]
numeros = {d.numero_affaire for d in decisions}
assert {"2026-906 DC", "2026-908 DC", "2026-909 DC"} <= numeros
# ─────────────────────────────────────────────────────────────────────────────
# Sénat
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def lois_senat() -> list:
document = HTMLParser(
(FIXTURES / "senat_lois_promulguees.html").read_text(encoding="utf-8")
)
collecteur = CollecteurSenat(ClientHttp(hors_ligne=True))
textes = []
for ancre in document.css("a"):
libelle = " ".join(ancre.text(separator=" ", strip=True).split())
if texte := collecteur._lire_intitule(libelle, ancre.attributes.get("href")):
textes.append(texte)
return textes
def test_senat_releve_les_lois_de_la_fenetre(lois_senat) -> None:
numeros = {t.numero_officiel for t in lois_senat}
# Quatre lois de la fenêtre de veille, aux dates connues du corpus.
assert {"2026-651", "2026-650", "2026-630", "2026-602"} <= numeros
def test_senat_lit_dates_et_nature(lois_senat) -> None:
par_numero = {t.numero_officiel: t for t in lois_senat}
justice = par_numero["2026-651"]
assert justice.date_promulgation == date(2026, 7, 23)
assert justice.statut is Statut.PROMULGUEE
assert justice.type is TypeTexte.LOI
def test_les_deux_sources_ne_disent_pas_la_meme_chose_du_caractere_organique(
lois_senat,
) -> None:
"""Asymétrie réelle entre les sources, à ne pas corriger en silence.
Légifrance intitule la loi n° 2026-650 « LOI organique n° 2026-650 … » ;
la liste du Sénat écrit « Loi n° 2026-650 … » sans la qualifier. Le
collecteur rapporte ce que sa source dit — c'est le rôle du rapprochement
de ne pas laisser la source la plus pauvre écraser la plus riche. Le run de
mise à jour ne compare d'ailleurs pas le champ `type`.
"""
organique = par_numero_senat(lois_senat)["2026-650"]
assert organique.type is TypeTexte.LOI, (
"le Sénat ne qualifie pas cette loi d'organique : le collecteur ne doit "
"pas le deviner à sa place"
)
def par_numero_senat(lois: list) -> dict:
return {t.numero_officiel: t for t in lois}
def test_senat_ne_confond_pas_l_objet_avec_l_etat(lois_senat) -> None:
"""Le libellé se termine par « parue » : ce n'est pas l'objet de la loi."""
for texte in lois_senat:
assert not texte.titre.rstrip().endswith("parue")
assert "en cours" not in texte.titre
def test_senat_produit_une_source_primaire(lois_senat) -> None:
for texte in lois_senat:
assert texte.sources
assert texte.sources[0].tier == "T1"
assert texte.sources[0].url.startswith("https://www.senat.fr")
# ─────────────────────────────────────────────────────────────────────────────
# Légifrance
# ─────────────────────────────────────────────────────────────────────────────
def test_legifrance_indisponible_sans_identifiants(monkeypatch) -> None:
monkeypatch.delenv("LEGIFRANCE_CLIENT_ID", raising=False)
collecteur = CollecteurLegifrance(ClientHttp(hors_ligne=True))
disponible, motif = collecteur.est_disponible()
assert disponible is False
assert "repli" in (motif or "")
def test_legifrance_lit_un_resultat_de_recherche() -> None:
titre = {
"id": "LEGITEXT000054249605_14-06-2026",
"cid": "JORFTEXT000054245243",
"title": "LOI n° 2026-491 du 12 juin 2026 visant à reconnaître la responsabilité de l'Etat",
}
texte = _lire_titre(titre, "2026-491")
assert texte is not None
assert texte.date_promulgation == date(2026, 6, 12)
assert texte.source_url.endswith("JORFTEXT000054245243")
assert texte.sources[0].tier == "T1"
def test_legifrance_ecarte_un_resultat_hors_sujet() -> None:
titre = {"cid": "X", "title": "LOI n° 2026-999 du 1er janvier 2026 sans rapport"}
assert _lire_titre(titre, "2026-491") is None
def test_legifrance_deduit_le_caractere_organique() -> None:
titre = {
"cid": "JORF1",
"title": "LOI organique n° 2026-650 du 23 juillet 2026 relative aux juridictions",
}
texte = _lire_titre(titre, "2026-650")
assert texte.type is TypeTexte.LOI_ORGANIQUE
# ─────────────────────────────────────────────────────────────────────────────
# Client HTTP
# ─────────────────────────────────────────────────────────────────────────────
def test_le_cache_distingue_les_corps_de_requete() -> None:
a = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-491"}, None)
b = _cle_de_cache("POST", "https://x.fr/search", {"q": "2026-403"}, None)
assert a != b
def test_mode_hors_ligne_ne_touche_pas_au_reseau(tmp_path) -> None:
client = ClientHttp(cache=tmp_path, hors_ligne=True)
reponse = client.get("https://exemple.invalide/rien")
assert reponse.code == 0
assert not reponse.a_reussi
def test_le_cache_est_relu(tmp_path) -> None:
client = ClientHttp(cache=tmp_path, hors_ligne=True)
cle = _cle_de_cache("GET", "https://x.fr/a", None, None)
client._ecrire_cache(cle, "contenu")
reponse = client.get("https://x.fr/a")
assert reponse.texte == "contenu"
assert reponse.depuis_le_cache
# ─────────────────────────────────────────────────────────────────────────────
# Rapprochement
# ─────────────────────────────────────────────────────────────────────────────
def test_normalisation_retire_le_vocabulaire_passe_partout() -> None:
assert "loi" not in normaliser("LOI n° 2026-491 relative au chlordécone")
assert "chlordecone" in normaliser("LOI n° 2026-491 relative au chlordécone")
def test_similarite_reconnait_un_titre_court_dans_un_titre_long() -> None:
court = "Loi Riposte ordre public sécurité tranquillité"
long = (
"Loi visant à offrir des réponses immédiates aux phénomènes troublant "
"l'ordre public, la sécurité et la tranquillité de nos concitoyens"
)
assert similarite(court, long) >= 0.72
def test_le_numero_officiel_prime_sur_le_titre() -> None:
correspondance = rapprocher(
titre="Un intitulé totalement différent",
numero_officiel="2026-491",
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
)
assert correspondance.issue is Issue.RAPPROCHE
assert correspondance.texte_id == "loi-2026-491"
assert correspondance.score == 1.0
def test_un_texte_inconnu_est_declare_nouveau() -> None:
correspondance = rapprocher(
titre="Loi sur les moniteurs de ski stagiaires en zone de montagne",
numero_officiel=None,
candidats=[("loi-2026-491", "Chlordécone : responsabilité de l'État", "2026-491")],
)
assert correspondance.issue is Issue.NOUVEAU
def test_une_correspondance_douteuse_est_signalee_pas_fusionnee() -> None:
"""Entre les deux seuils, le pipeline s'abstient et demande un humain."""
correspondance = rapprocher(
titre="Loi relative à la protection des enfants et des mineurs",
numero_officiel=None,
candidats=[("pjl-protection-enfants", "PJL relatif à la protection des enfants", None)],
)
assert correspondance.issue in (Issue.RAPPROCHE, Issue.A_SIGNALER)
if correspondance.issue is Issue.A_SIGNALER:
assert "vérification humaine" in correspondance.motif
def test_aucun_candidat() -> None:
correspondance = rapprocher(titre="X", numero_officiel="2026-1", candidats=[])
assert correspondance.issue is Issue.NOUVEAU