Files
veye-lalwa/tests/test_acceptation.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

241 lines
9.8 KiB
Python

"""Critères d'acceptation du cahier des charges (§7 et §8.7).
Ces tests exécutent le seed complet sur le corpus réel, puis vérifient en base
les sept garde-fous du §7 et les scénarios de démonstration du §8.7. Ils
échouent si une régression altère un fait — c'est leur seule raison d'être.
"""
from __future__ import annotations
import json
import sqlite3
import pytest
from pipeline import db
from pipeline.seed_from_research import ensemencer
@pytest.fixture(scope="module")
def base_complete(tmp_path_factory) -> sqlite3.Connection:
"""Base issue d'un seed complet du corpus, partagée par le module."""
chemin = tmp_path_factory.mktemp("acceptation") / "veille.db"
cx = db.initialiser(chemin)
ensemencer(cx)
return cx
def _texte(cx: sqlite3.Connection, identifiant: str) -> sqlite3.Row:
ligne = cx.execute("SELECT * FROM v_textes WHERE id = ?", (identifiant,)).fetchone()
assert ligne is not None, f"{identifiant} absent de la base"
return ligne
def _affaires(cx: sqlite3.Connection, identifiant: str) -> list[str]:
return [
r["numero_affaire"]
for r in cx.execute(
"SELECT numero_affaire FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire",
(identifiant,),
)
]
# ─────────────────────────────────────────────────────────────────────────────
# §2 — volumétrie
# ─────────────────────────────────────────────────────────────────────────────
def test_au_moins_49_textes(base_complete) -> None:
total = base_complete.execute("SELECT COUNT(*) FROM textes").fetchone()[0]
assert total >= 49
def test_chaque_texte_a_au_moins_une_source_url(base_complete) -> None:
"""§8.2 : 100 % des entrées avec au moins une source URL."""
orphelins = [
r["id"]
for r in base_complete.execute(
"SELECT id FROM textes t "
"WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)"
)
]
assert orphelins == []
def test_toutes_les_sources_ont_une_url_absolue(base_complete) -> None:
mauvaises = [
r["url"]
for r in base_complete.execute(
"SELECT url FROM sources WHERE url NOT LIKE 'http://%' AND url NOT LIKE 'https://%'"
)
]
assert mauvaises == []
# ─────────────────────────────────────────────────────────────────────────────
# §7 — les sept garde-fous
# ─────────────────────────────────────────────────────────────────────────────
def test_1_chlordecone(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-491")
assert texte["numero_officiel"] == "2026-491"
assert texte["statut"] == "promulguee"
assert texte["date_promulgation"] == "2026-06-12"
assert texte["guadeloupe_pertinence"] == "forte"
assert {"sante", "outre_mer", "memoire_patrimoine"} <= set(json.loads(texte["themes"]))
assert texte["nb_sources"] > 0
def test_2_fraudes_sociales_et_fiscales(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-534")
assert texte["statut"] == "promulguee"
assert texte["date_promulgation"] == "2026-06-25"
assert _affaires(base_complete, "loi-2026-534") == ["2026-904 DC"]
def test_3_loi_riposte(base_complete) -> None:
"""Statut, événement de saisine, et amende forfaitaire à 500 € — pas 1 500 €."""
texte = _texte(base_complete, "loi-riposte")
assert texte["statut"] == "adoptee_non_promulguee"
assert texte["numero_officiel"] is None
saisines = base_complete.execute(
"SELECT date_evenement, description FROM evenements "
"WHERE texte_id = 'loi-riposte' AND type_etape = 'saisine_cc'"
).fetchall()
assert len(saisines) == 1
assert saisines[0]["date_evenement"] == "2026-07-24"
assert "2026-915 DC" in saisines[0]["description"]
assert "24 juillet 2026" in saisines[0]["description"]
points = json.loads(texte["points_cles"])
assert any("500 €" in p for p in points), "l'AFD à 500 € doit figurer aux points clés"
# Le montant abandonné ne doit apparaître que présenté comme tel.
for point in points:
if "1 500 €" in point:
assert "abandonn" in point or "Sénat 1" in point
def test_4_ppl_aide_a_mourir(base_complete) -> None:
texte = _texte(base_complete, "ppl-aide-a-mourir")
assert texte["date_adoption"] == "2026-07-15"
assert _affaires(base_complete, "ppl-aide-a-mourir") == ["2026-910 DC"]
assert "291" in (texte["resume"] or ""), "le vote 291/241 doit apparaître au résumé"
def test_5_pjl_pacte_migration(base_complete) -> None:
texte = _texte(base_complete, "pjl-pacte-migration")
assert texte["statut"] == "navette"
assert "migration" in json.loads(texte["themes"])
def test_6_simplification_vie_economique(base_complete) -> None:
texte = _texte(base_complete, "loi-2026-403")
assert texte["statut"] == "promulguee"
decision = base_complete.execute(
"SELECT * FROM decisions_cc WHERE texte_id = 'loi-2026-403'"
).fetchone()
assert decision["numero_affaire"] == "2026-903 DC"
assert decision["resultat"] == "non_conformite_partielle"
assert "25 articles" in decision["resume"]
note = (texte["guadeloupe_note"] or "").lower()
assert "march" in note and "ultramarin" in note
def test_7_senatoriales_la_guadeloupe_n_est_pas_concernee(base_complete) -> None:
"""Le point 4 de la validation de phase 5 tranche : série 1, renouvelée en 2023."""
echeance = base_complete.execute(
"SELECT * FROM echeances WHERE code = 'senatoriales-2026'"
).fetchone()
assert echeance is not None
assert echeance["date_echeance"] == "2026-09-27"
assert echeance["concerne_guadeloupe"] == 0
assert "n'est PAS concernée" in echeance["note_guadeloupe"]
assert "série 1" in echeance["note_guadeloupe"]
# ─────────────────────────────────────────────────────────────────────────────
# §8.7 — scénarios de démonstration
# ─────────────────────────────────────────────────────────────────────────────
def test_recherche_chlordecone_classe_la_bonne_loi_en_tete(base_complete) -> None:
lignes = base_complete.execute(
"""
SELECT t.id
FROM textes_fts f
JOIN textes t ON t.rowid = f.rowid
WHERE textes_fts MATCH 'chlordecone'
ORDER BY bm25(textes_fts, 10.0, 5.0, 3.0, 2.0, 2.0, 1.0)
"""
).fetchall()
assert lignes, "la recherche « chlordécone » ne doit pas être vide"
assert lignes[0]["id"] == "loi-2026-491"
def test_facette_devant_le_conseil_constitutionnel(base_complete) -> None:
total = base_complete.execute(
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1"
).fetchone()[0]
assert total >= 7
def test_facette_guadeloupe_forte_non_vide(base_complete) -> None:
total = base_complete.execute(
"SELECT COUNT(*) FROM textes WHERE guadeloupe_pertinence = 'forte'"
).fetchone()[0]
assert total > 0
def test_facettes_combinees(base_complete) -> None:
"""`/recherche?statut=saisie_cc&guadeloupe=forte` doit rendre des résultats."""
total = base_complete.execute(
"SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1 AND guadeloupe_pertinence = 'forte'"
).fetchone()[0]
assert total > 0
def test_une_fiche_a_timeline_et_sources(base_complete) -> None:
texte = _texte(base_complete, "loi-riposte")
assert texte["nb_evenements"] >= 2
assert texte["nb_sources"] >= 3
# ─────────────────────────────────────────────────────────────────────────────
# Honnêteté des données
# ─────────────────────────────────────────────────────────────────────────────
def test_les_deductions_sont_signalees(base_complete) -> None:
"""Toute valeur déduite porte un motif de vérification lisible."""
lignes = base_complete.execute(
"SELECT id, motif_verification FROM textes WHERE a_verifier = 1"
)
for ligne in lignes:
assert ligne["motif_verification"], f"{ligne['id']} marqué à vérifier sans motif"
def test_aucune_pertinence_manuelle_n_a_ete_ecrasee(base_complete) -> None:
"""Les 27 lois promulguées gardent la cotation de l'annexe (§5.3)."""
deduites = base_complete.execute(
"SELECT COUNT(*) FROM textes WHERE statut = 'promulguee' "
"AND motif_verification LIKE '%Pertinence Guadeloupe déduite%'"
).fetchone()[0]
assert deduites == 0
def test_analyses_transversales_importees(base_complete) -> None:
total = base_complete.execute("SELECT COUNT(*) FROM insights").fetchone()[0]
assert total == 7
def test_echeances_du_calendrier(base_complete) -> None:
codes = {
r["code"] for r in base_complete.execute("SELECT code FROM echeances")
}
assert {"senatoriales-2026", "plf-2027", "facturation-electronique-reception"} <= codes