Files
veye-lalwa/tests/test_collecteurs.py
T
Cyber MawonajandClaude Opus 5 feb5544599 Phase 5 : collecteurs officiels et run de veille — dry-run contre sources réelles
Collecteurs, chacun isolé et tolérant à l'échec :
- legifrance : OAuth PISTE validé en production. Deux pièges vérifiés sur le
  compte réel — seul le couple « Client ID / Client secret » ouvre un jeton
  (la clé d'API donne invalid_client), et les identifiants de production ne
  passent pas en bac à sable.
- senat : liste chronologique des lois promulguées. L'URL du §5.1
  (senat.fr/lois/index.html) est morte ; l'équivalent fonctionnel est
  /dossiers-legislatifs/lois-promulguees.html — 31 lois 2026 relevées.
- conseil_constitutionnel : sans le paramètre ?id=32246 la page des affaires en
  instance ne rend que des QPC et aucune affaire DC. Les colonnes DC et QPC
  diffèrent : lecture par en-tête, jamais par position.

Rapprochement à trois issues — numéro officiel, similarité de titre, et une
zone de signalement où le pipeline s'abstient plutôt que de fusionner.

make update-dry contre les sources réelles : aucun faux positif sur les 52
textes du corpus, et trois apports que le rapport n'avait pas — le numéro
d'affaire 2026-907 DC de la programmation militaire, la date de saisine du
16 juillet pour l'aide à mourir, les décisions du 23 juillet sur 908 et 909.

Une asymétrie de sources est documentée par un test plutôt que corrigée en
douce : le Sénat ne qualifie pas la loi 2026-650 d'organique, Légifrance si.
Le collecteur rapporte ce que sa source dit ; le champ  n'est pas comparé.

Fixtures HTML figées : aucun test ne touche au réseau.
156 tests, 91 % de couverture (chaque module au-dessus du seuil de 70 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:48:56 -04:00

300 lines
13 KiB
Python

"""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