metalfrom.eu/apps/geocoder/tests/test_parser_property.py
Nicolas Fryder 83ea8030e7
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
test: propriétés généralisées, scraper HTML et couche réseau couverts
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.

SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.

COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.

PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
  centroïdes. Invariants — jamais d'exception, structure toujours complète,
  aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
  cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
  sans effet de bord ni perte d'éléments, clés de coordonnées.

max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».

Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 22:09:37 +02:00

146 lines
6.3 KiB
Python

"""
Propriétés du parseur de localisations.
Ce module décide combien de lignes `band_locations` sont créées, donc combien
d'appels Geoapify et Groq — facturés — seront déclenchés. Les tests par
l'exemple ne couvrent que les formats qu'on a vus ; Hypothesis explore les
autres, en particulier ce que Metal Archives contient réellement : parenthèses
déséquilibrées, points-virgules en série, unicode, chaînes très longues.
L'invariant qui compte : quelle que soit l'entrée, la fonction rend une liste
de lignes bien formées ou une liste vide — jamais d'exception, jamais une clé
manquante. Une exception ici interromprait l'enqueue de toute la file.
max_examples est fixé à 150 : `npm run check` tourne des dizaines de fois par
jour et ces tests en constituaient le chemin critique. 150 tirages ont suffi à
trouver le contre-exemple « 0/0 » (villes dupliquées dans une même étape) ;
au-delà, on paie sans rien apprendre de plus. Augmenter ponctuellement pour
une chasse ciblée.
"""
from hypothesis import given, settings
from hypothesis import strategies as st
from src.parser import build_fallback_queries, country_centroid, parse_location_text
# Caractères que MA utilise réellement comme séparateurs ou décorations.
LOCATION_TEXT = st.text(
alphabet=st.sampled_from(
list("abcdefghijklmnopqrstuvwxyzÀÉÖøå ,;/()-.'0123456789") + ["\t", "\n"]
),
max_size=120,
)
COUNTRY = st.one_of(
st.sampled_from(["FR", "NO", "DE", "SE", "GB", "ZZ", "fr", ""]),
st.none(),
st.text(max_size=5),
)
class TestParseLocationTextTotalite:
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_ne_leve_jamais_et_rend_toujours_une_liste(self, texte):
assert isinstance(parse_location_text(texte), list)
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_chaque_ligne_a_exactement_les_cles_attendues(self, texte):
# db.py insère ces clés sans les vérifier : une clé manquante ferait
# échouer l'enqueue au milieu du lot.
for row in parse_location_text(texte):
assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"}
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_les_types_de_chaque_champ_sont_stables(self, texte):
for row in parse_location_text(texte):
assert isinstance(row["step_order"], int)
assert isinstance(row["is_country_only"], bool)
assert isinstance(row["location_raw"], str)
assert row["step_label"] is None or isinstance(row["step_label"], str)
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_aucun_lieu_vide_nest_produit(self, texte):
# Un lieu vide partirait en géocodage et consommerait un appel facturé
# pour rien.
for row in parse_location_text(texte):
assert row["location_raw"].strip() != ""
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_les_step_order_sont_positifs_et_croissants(self, texte):
orders = [r["step_order"] for r in parse_location_text(texte)]
assert all(o >= 0 for o in orders)
assert orders == sorted(orders)
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT)
def test_la_contrainte_dunicite_de_la_base_est_respectee(self, texte):
# band_locations a UNIQUE (ma_id, step_order, location_raw) : deux lignes
# identiques dans un même lot feraient échouer l'insertion.
rows = parse_location_text(texte)
cles = [(r["step_order"], r["location_raw"]) for r in rows]
assert len(cles) == len(set(cles))
@settings(max_examples=150, deadline=None)
@given(st.text(max_size=400))
def test_supporte_du_texte_totalement_arbitraire(self, texte):
assert isinstance(parse_location_text(texte), list)
@settings(max_examples=150, deadline=None)
@given(LOCATION_TEXT, COUNTRY)
def test_le_pays_du_groupe_ne_casse_rien(self, texte, pays):
assert isinstance(parse_location_text(texte, pays), list)
class TestBuildFallbackQueriesProprietes:
@settings(max_examples=150, deadline=None)
@given(st.text(min_size=1, max_size=100), COUNTRY)
def test_ne_leve_jamais_et_rend_une_liste_de_chaines(self, brut, pays):
out = build_fallback_queries(brut, pays)
assert isinstance(out, list)
assert all(isinstance(q, str) for q in out)
@settings(max_examples=150, deadline=None)
@given(st.text(min_size=1, max_size=100), COUNTRY)
def test_aucun_doublon(self, brut, pays):
# Chaque requête est un appel Geoapify facturé : un doublon, c'est de
# l'argent jeté.
out = build_fallback_queries(brut, pays)
assert len(out) == len(set(out))
@settings(max_examples=150, deadline=None)
@given(st.text(min_size=1, max_size=100), COUNTRY)
def test_aucune_requete_vide(self, brut, pays):
assert all(q.strip() != "" for q in build_fallback_queries(brut, pays))
@settings(max_examples=150, deadline=None)
@given(st.text(min_size=1, max_size=60), COUNTRY)
def test_le_nombre_de_requetes_reste_borne(self, brut, pays):
# Le repli découpe sur les virgules : sans borne, une entrée pathologique
# générerait des dizaines d'appels facturés pour un seul lieu.
n_virgules = brut.count(",")
assert len(build_fallback_queries(brut, pays)) <= 2 * (n_virgules + 2) + 2
@settings(max_examples=150, deadline=None)
@given(st.text(alphabet=st.characters(blacklist_categories=("Cs",)), min_size=1, max_size=50))
def test_supporte_lunicode_arbitraire(self, brut):
assert isinstance(build_fallback_queries(brut, "FR"), list)
class TestCountryCentroidProprietes:
@settings(max_examples=150, deadline=None)
@given(st.text(max_size=10))
def test_rend_none_ou_des_coordonnees_valides(self, code):
r = country_centroid(code)
if r is not None:
lat, lon = r
assert -90 <= lat <= 90
assert -180 <= lon <= 180
@settings(max_examples=150, deadline=None)
@given(st.sampled_from(["FR", "NO", "DE", "SE", "IT", "ES", "PL"]))
def test_insensible_a_la_casse_et_aux_espaces(self, code):
assert country_centroid(code) == country_centroid(f" {code.lower()} ")