Briques réutilisables : - dates_fr : lit les formes réelles du corpus (« 23 avr. 2026 (JO 24 avr.) », « 1er juill. », « 24.07.2026 ») et distingue une date exacte d'une échéance prévisionnelle (« Fin août 2026 » → situé au 25, libellé conservé, jamais présenté comme un fait) - markdown : découpage des tableaux (barres échappées, lignes courtes), nettoyage, extraction des marqueurs [^dim07-24^] / [^phase5-3.1^] Parseur sec10 : - 27 lois promulguées + 21 lignes simples + 2 lignes éclatées en 4 textes = 52 - éclatement justifié : les lignes « accords Kazakhstan et Colombie » et « MACF » décrivent chacune deux textes de statuts différents - migration 002 : regime_libertes stocké à part, faute de savoir quel public la cotation globale du rapport vise — dérouler « droits+ » sur les trois publics aurait inventé une ventilation absente de la source - migration 003 : la facette « devant le CC » couvre aussi les saisines sans numéro d'affaire (programmation militaire), sinon 6 textes au lieu de 7 Trois pièges du corpus, corrigés et couverts par des tests : - « eau » matchait comme sous-chaîne dans réseaux / drapeau / Retailleau - une décision citée sans saisine créait une fausse saisine (loi Philippine) - la date de saisine était pêchée dans la colonne « échéance », datant la saisine de l'aide à mourir du mois suivant 72 tests, 90 % de couverture sur pipeline/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
14 KiB
Python
306 lines
14 KiB
Python
"""Parseur de l'annexe récapitulative.
|
|
|
|
Ces tests portent sur le corpus réel : ils échouent si le format d'entrée
|
|
change, ce qui est le comportement voulu — un seed silencieusement incomplet
|
|
serait pire qu'une erreur bruyante.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from pipeline.modeles import (
|
|
Pertinence,
|
|
RegimeLibertes,
|
|
ResultatCC,
|
|
Statut,
|
|
Theme,
|
|
TypeTexte,
|
|
)
|
|
from pipeline.parseurs.tableaux_sec10 import (
|
|
_deduire_themes,
|
|
_lire_pertinence,
|
|
_lire_regime,
|
|
_lire_statut,
|
|
extraire_marqueurs_annexe,
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Volumétrie
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_aucun_avertissement(textes_annexe) -> None:
|
|
assert textes_annexe.avertissements == []
|
|
|
|
|
|
def test_volumetrie_minimale(textes_annexe) -> None:
|
|
# 27 lois promulguées + 23 lignes du tableau B, dont 2 éclatées en deux
|
|
# textes chacune : 27 + 21 + 4 = 52.
|
|
assert len(textes_annexe) == 52
|
|
assert len(textes_annexe) >= 49, "le cahier des charges exige au moins 49 textes"
|
|
|
|
|
|
def test_identifiants_uniques(textes_annexe) -> None:
|
|
identifiants = [t.id for t in textes_annexe.textes]
|
|
assert len(identifiants) == len(set(identifiants))
|
|
|
|
|
|
def test_toutes_les_lois_promulguees_ont_numero_et_date(textes_annexe) -> None:
|
|
promulguees = [t for t in textes_annexe.textes if t.statut is Statut.PROMULGUEE]
|
|
assert len(promulguees) == 27
|
|
for texte in promulguees:
|
|
assert texte.numero_officiel, texte.id
|
|
assert texte.date_promulgation, texte.id
|
|
assert texte.regime_libertes is not None, texte.id
|
|
assert texte.guadeloupe_pertinence is not None, texte.id
|
|
|
|
|
|
def test_aucun_texte_non_promulgue_ne_porte_de_numero(textes_annexe) -> None:
|
|
for texte in textes_annexe.textes:
|
|
if texte.statut is not Statut.PROMULGUEE:
|
|
assert texte.numero_officiel is None, texte.id
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Contrôles d'acceptation du §7 du cahier des charges
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_loi_chlordecone(par_identifiant) -> None:
|
|
texte = par_identifiant["loi-2026-491"]
|
|
assert texte.numero_officiel == "2026-491"
|
|
assert texte.statut is Statut.PROMULGUEE
|
|
assert texte.date_promulgation == date(2026, 6, 12)
|
|
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
|
assert {Theme.SANTE, Theme.OUTRE_MER, Theme.MEMOIRE_PATRIMOINE} <= set(texte.themes)
|
|
|
|
|
|
def test_loi_fraudes_et_sa_decision_cc(par_identifiant) -> None:
|
|
texte = par_identifiant["loi-2026-534"]
|
|
assert texte.statut is Statut.PROMULGUEE
|
|
assert texte.date_promulgation == date(2026, 6, 25)
|
|
assert [d.numero_affaire for d in texte.decisions_cc] == ["2026-904 DC"]
|
|
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
|
|
|
|
|
def test_loi_riposte_statut_et_evenement_de_saisine(par_identifiant) -> None:
|
|
"""Le §7 impose le statut `adoptee_non_promulguee` ET l'événement de saisine."""
|
|
texte = par_identifiant["loi-riposte"]
|
|
assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE
|
|
assert texte.numero_officiel is None
|
|
|
|
saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"]
|
|
assert len(saisines) == 1
|
|
assert "2026-915 DC" in saisines[0].description
|
|
assert "24 juillet 2026" in saisines[0].description
|
|
assert saisines[0].date_evenement == date(2026, 7, 24)
|
|
|
|
(affaire,) = texte.decisions_cc
|
|
assert affaire.numero_affaire == "2026-915 DC"
|
|
assert affaire.resultat is ResultatCC.EN_INSTANCE
|
|
assert affaire.date_saisine == date(2026, 7, 24)
|
|
|
|
|
|
def test_ppl_aide_a_mourir(par_identifiant) -> None:
|
|
texte = par_identifiant["ppl-aide-a-mourir"]
|
|
assert texte.type is TypeTexte.PPL
|
|
assert texte.statut is Statut.ADOPTEE_NON_PROMULGUEE
|
|
assert texte.date_adoption == date(2026, 7, 15)
|
|
(affaire,) = texte.decisions_cc
|
|
assert affaire.numero_affaire == "2026-910 DC"
|
|
# Le corpus ne date pas cette saisine : rien ne doit être inventé.
|
|
assert affaire.date_saisine is None
|
|
assert affaire.date_decision_attendue == date(2026, 8, 20)
|
|
|
|
|
|
def test_pjl_pacte_migration(par_identifiant) -> None:
|
|
texte = par_identifiant["pjl-pacte-migration"]
|
|
assert texte.type is TypeTexte.PJL
|
|
assert texte.statut is Statut.NAVETTE
|
|
assert Theme.MIGRATION in texte.themes
|
|
|
|
|
|
def test_loi_simplification_economique(par_identifiant) -> None:
|
|
texte = par_identifiant["loi-2026-403"]
|
|
assert texte.statut is Statut.PROMULGUEE
|
|
(affaire,) = texte.decisions_cc
|
|
assert affaire.numero_affaire == "2026-903 DC"
|
|
assert affaire.resultat is ResultatCC.NON_CONFORMITE_PARTIELLE
|
|
assert "25 articles censurés" in (affaire.resume or "")
|
|
assert texte.guadeloupe_pertinence is Pertinence.FORTE
|
|
|
|
|
|
def test_loi_philippine_est_validee_sans_fausse_saisine(par_identifiant) -> None:
|
|
"""Sa cellule cite une décision, pas une saisine : aucun événement de saisine."""
|
|
texte = par_identifiant["loi-philippine"]
|
|
assert texte.statut is Statut.VALIDEE_CC
|
|
assert [e.type_etape for e in texte.evenements if e.type_etape == "saisine_cc"] == []
|
|
decisions = [e for e in texte.evenements if e.type_etape == "decision_cc"]
|
|
assert decisions and decisions[0].date_evenement == date(2026, 7, 23)
|
|
|
|
|
|
def test_programmation_militaire_saisie_sans_numero_d_affaire(par_identifiant) -> None:
|
|
"""« Conseil constitutionnel saisi le 6 juill. » : événement, pas d'affaire."""
|
|
texte = par_identifiant["loi-programmation-militaire"]
|
|
saisines = [e for e in texte.evenements if e.type_etape == "saisine_cc"]
|
|
assert len(saisines) == 1
|
|
assert saisines[0].date_evenement == date(2026, 7, 6)
|
|
assert texte.decisions_cc == []
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Éclatement des lignes doubles
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_accords_bilateraux_eclates(par_identifiant) -> None:
|
|
kazakhstan = par_identifiant["accord-kazakhstan-readmission"]
|
|
colombie = par_identifiant["accord-colombie-extradition"]
|
|
assert kazakhstan.type is TypeTexte.ACCORD_INTERNATIONAL
|
|
assert kazakhstan.date_adoption == date(2026, 1, 28)
|
|
assert colombie.date_adoption == date(2026, 2, 18)
|
|
|
|
|
|
def test_macf_eclate_en_deux_statuts(par_identifiant) -> None:
|
|
"""La ligne MACF décrit deux textes de statuts différents."""
|
|
ratification = par_identifiant["pjl-ratification-ordonnance-macf"]
|
|
accord = par_identifiant["accord-macf-plateforme-centrale"]
|
|
assert ratification.statut is Statut.DEPOSEE_NON_EXAMINEE
|
|
assert accord.statut is Statut.ANNONCEE
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Lecture de cellules
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cellule", "attendu"),
|
|
[
|
|
("**Droits+** : contrôle externe de la détention", RegimeLibertes.DROITS_PLUS),
|
|
("**Contrôle+** : durées réduites", RegimeLibertes.CONTROLE_PLUS),
|
|
("Mixte : anti-impayés ; vigilance débiteur", RegimeLibertes.MIXTE),
|
|
("Neutre", RegimeLibertes.NEUTRE),
|
|
("Neutre à **droits+** : équité territoriale", RegimeLibertes.DROITS_PLUS),
|
|
],
|
|
)
|
|
def test_lire_regime(cellule: str, attendu: RegimeLibertes) -> None:
|
|
regime, note = _lire_regime(cellule)
|
|
assert regime is attendu
|
|
assert note
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cellule", "attendu", "note"),
|
|
[
|
|
("Forte — maison d'arrêt de Baie-Mahault", Pertinence.FORTE, "maison d'arrêt"),
|
|
("Moyenne — CPCE applicable aux DROM", Pertinence.MOYENNE, "CPCE"),
|
|
("Faible — sans objet", Pertinence.FAIBLE, "sans objet"),
|
|
],
|
|
)
|
|
def test_lire_pertinence(cellule: str, attendu: Pertinence, note: str) -> None:
|
|
pertinence, motif = _lire_pertinence(cellule)
|
|
assert pertinence is attendu
|
|
assert note in (motif or "")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cellule", "attendu"),
|
|
[
|
|
("**Adoptée, non promulguée** (21 juill., CMP)", Statut.ADOPTEE_NON_PROMULGUEE),
|
|
("**En navette** : Sénat 1re lecture 20 mai", Statut.NAVETTE),
|
|
("**Déposé, non examiné** : Sénat n° 890", Statut.DEPOSEE_NON_EXAMINEE),
|
|
("**Annoncé** : transmis au Conseil d'État", Statut.ANNONCEE),
|
|
("**Adoptée par une chambre** : Sénat 5-6 mai", Statut.NAVETTE),
|
|
("**Adoptée en 1re lecture** : AN 28 mai", Statut.NAVETTE),
|
|
("**Adoptée** (15-16 juin) ; conforme avec réserves ; **promulgation pendante**",
|
|
Statut.VALIDEE_CC),
|
|
],
|
|
)
|
|
def test_lire_statut(cellule: str, attendu: Statut) -> None:
|
|
assert _lire_statut(cellule) is attendu
|
|
|
|
|
|
def test_lire_statut_refuse_l_inconnu() -> None:
|
|
with pytest.raises(ValueError, match="statut non reconnu"):
|
|
_lire_statut("Texte au statut fantaisiste")
|
|
|
|
|
|
def test_themes_ne_confondent_pas_les_sous_chaines() -> None:
|
|
"""« eau » ne doit pas se déclencher sur « réseaux », « drapeau », « Retailleau »."""
|
|
assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL réseaux sociaux et mineurs")
|
|
assert Theme.ENVIRONNEMENT not in _deduire_themes("outrage à l'hymne et au drapeau")
|
|
assert Theme.ENVIRONNEMENT not in _deduire_themes("PPL entrisme Retailleau")
|
|
# Contrôle positif : le mot isolé compte bien.
|
|
assert Theme.ENVIRONNEMENT in _deduire_themes("habilitation Martinique énergie et eau")
|
|
|
|
|
|
def test_themes_plafonnes_a_quatre() -> None:
|
|
long_texte = " ".join(
|
|
["justice", "sécurité", "numérique", "social", "fiscal", "agriculture", "santé"]
|
|
)
|
|
assert len(_deduire_themes(long_texte)) <= 4
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Marqueurs de citation
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_marqueurs_de_l_annexe(annexe: str) -> None:
|
|
associations = extraire_marqueurs_annexe(annexe)
|
|
assert "2026-491" in associations
|
|
assert "dim07-24" in associations["2026-491"]
|
|
riposte = next(cle for cle in associations if cle.startswith("Loi « Riposte »"))
|
|
assert "phase5-3.1" in associations[riposte]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Écriture en base
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_seed_en_base(base_ensemencee) -> None:
|
|
cx = base_ensemencee
|
|
assert cx.execute("SELECT COUNT(*) FROM textes").fetchone()[0] == 52
|
|
assert cx.execute("SELECT COUNT(*) FROM decisions_cc").fetchone()[0] == 12
|
|
|
|
|
|
def test_facette_devant_cc(base_ensemencee) -> None:
|
|
"""Le §8.7 attend au moins 7 textes suspendus au Conseil constitutionnel."""
|
|
total = base_ensemencee.execute(
|
|
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1"
|
|
).fetchone()[0]
|
|
assert total >= 7
|
|
|
|
|
|
def test_les_lois_promulguees_ne_sont_pas_devant_le_cc(base_ensemencee) -> None:
|
|
"""Une décision rendue mais non datée ne doit pas passer pour une instance."""
|
|
restantes = base_ensemencee.execute(
|
|
"SELECT id FROM v_textes WHERE devant_cc = 1 AND statut = 'promulguee'"
|
|
).fetchall()
|
|
assert restantes == []
|
|
|
|
|
|
def test_recherche_plein_texte(base_ensemencee) -> None:
|
|
"""La recherche doit trouver « chlordécone » sans les accents."""
|
|
lignes = base_ensemencee.execute(
|
|
"SELECT t.id FROM textes_fts f JOIN textes t ON t.rowid = f.rowid "
|
|
"WHERE textes_fts MATCH 'chlordecone'"
|
|
).fetchall()
|
|
assert [ligne["id"] for ligne in lignes] == ["loi-2026-491"]
|
|
|
|
|
|
def test_ecriture_idempotente(base, textes_annexe) -> None:
|
|
"""Rejouer le seed ne duplique ni textes, ni sources, ni événements."""
|
|
from pipeline import db
|
|
|
|
db.enregistrer_textes(base, textes_annexe.textes)
|
|
premier = db.statistiques(base)
|
|
db.enregistrer_textes(base, textes_annexe.textes)
|
|
second = db.statistiques(base)
|
|
assert premier == second
|