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